aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli/src/linter
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_cli/src/linter')
-rw-r--r--mingling_cli/src/linter/cmd_mlint_install.rs323
1 files changed, 235 insertions, 88 deletions
diff --git a/mingling_cli/src/linter/cmd_mlint_install.rs b/mingling_cli/src/linter/cmd_mlint_install.rs
index d29d3e5..c390a43 100644
--- a/mingling_cli/src/linter/cmd_mlint_install.rs
+++ b/mingling_cli/src/linter/cmd_mlint_install.rs
@@ -4,7 +4,7 @@ use crate::linter::mlint_report::{
};
use mingling::Routable;
use mingling::macros::{buffer, chain, dispatcher, pack, r_eprintln, renderer, routeify};
-use mingling::res::ResCurrentDir;
+use mingling::res::{ResCurrentDir, ResExitCode};
use std::ops::Range;
use std::path::PathBuf;
@@ -13,13 +13,15 @@ const EXPECTED_LINE: &str = r#"check.overrideCommand = ["mling", "ra-lint-check"
const VALID_FIRST: &[&str] = &["mling", "mingling-cli"];
const RA_CONFIG_TEMPLATE: &str = include_str!("../../tmpls/rust-analyzer.toml");
+// Rust-analyzer TOML section
+const RA_TABLE: &str = "rust-analyzer";
+
// Key names
const KEY_CHECK_ON_SAVE: &str = "checkOnSave";
const VALUE_CHECK_ON_SAVE_TRUE: &str = "true";
const DISPLAY_CHECK_ON_SAVE_TRUE: &str = "checkOnSave = true";
// File names
-const CFG_FILE_NAME: &str = ".rust-analyzer.toml";
const SOURCE_FILE_NAME: &str = "rust-analyzer.toml";
const MSG_ALREADY_CORRECT: &str = "`.rust-analyzer.toml` already has the correct mling settings";
@@ -37,8 +39,6 @@ const SUGGEST_RA_LINT_CHECK_ARRAY: &str = r#"["mling", "ra-lint-check"]"#;
const SUGGEST_MLING_QUOTED: &str = r#""mling""#;
const SUGGEST_RA_LINT_CHECK_QUOTED: &str = r#""ra-lint-check""#;
const SUGGEST_MESSAGE_FORMAT_JSON: &str = ", \"--message-format=json\"]";
-const SUGGEST_OVERRIDE_LINE: &str = "overrideCommand = [\"mling\", \"ra-lint-check\"]\n";
-const SUGGEST_OVERRIDE_FULL_LINE: &str = r#"check.overrideCommand = ["mling", "ra-lint-check"]"#;
// Subcommand constants
const SUB_CMD_LINT: &str = "lint";
@@ -52,7 +52,7 @@ pack!(ResultMlingLinterConfigInstalled = PathBuf);
#[chain]
pub fn handle_lint_install(_: EntryLintInstall, current_dir: &ResCurrentDir) -> Next {
- let cfg_file_path = current_dir.join(CFG_FILE_NAME);
+ let cfg_file_path = current_dir.join(SOURCE_FILE_NAME);
if !cfg_file_path.exists() {
return StateWriteMlingLinterConfig::new(cfg_file_path).to_chain();
@@ -81,8 +81,11 @@ pub fn render_mling_linter_config_installed(result: ResultMlingLinterConfigInsta
pub fn handle_state_suggest_mling_linter_setup(
_: StateSuggestMlingLinterSetup,
current_dir: &ResCurrentDir,
+ ec: &mut ResExitCode,
) -> StateLintReports {
- let cfg_file_path = current_dir.join(CFG_FILE_NAME);
+ ec.exit_code = 1;
+
+ let cfg_file_path = current_dir.join(SOURCE_FILE_NAME);
let file_name = cfg_file_path.to_string_lossy().to_string();
let content = match std::fs::read_to_string(&cfg_file_path) {
@@ -97,14 +100,21 @@ pub fn handle_state_suggest_mling_linter_setup(
};
let mut reports: Vec<MlintReport> = vec![];
- reports.extend(check_simple_key(
+
+ reports.extend(check_simple_key_in_section(
&content,
KEY_CHECK_ON_SAVE,
VALUE_CHECK_ON_SAVE_TRUE,
DISPLAY_CHECK_ON_SAVE_TRUE,
SOURCE_FILE_NAME,
+ RA_TABLE,
+ ));
+
+ reports.extend(check_override_command_in_section(
+ &content,
+ SOURCE_FILE_NAME,
+ RA_TABLE,
));
- reports.extend(check_override_command(&content, SOURCE_FILE_NAME));
if reports.is_empty() {
reports.push(MlintReport {
@@ -176,14 +186,213 @@ fn with_insert_suggestion(report: MlintReport, line: usize, new_content: String)
}
}
-fn check_simple_key(
+/// Result of looking up a key=value pair in TOML content.
+type FoundKey = Option<(usize, String)>;
+
+/// Search for a key=value pair within a TOML section (e.g. `[rust-analyzer]`).
+///
+/// Scans the content between `[section_name]` (and its child tables like
+/// `[section_name.check]`) and the next sibling section.
+///
+/// `dotted_key` can be a simple name like `"checkOnSave"` or a dotted path
+/// like `"check.overrideCommand"`. In the latter case it matches both the
+/// dotted form (`check.overrideCommand = ...`) and the bare form inside a
+/// child table (`overrideCommand = ...` under `[rust-analyzer.check]`).
+fn find_key_in_section(content: &str, dotted_key: &str, section_name: &str) -> FoundKey {
+ let parts: Vec<&str> = dotted_key.split('.').collect();
+ let field = parts.last().copied().unwrap_or(dotted_key);
+
+ let section_header = format!("[{section_name}]");
+ let mut in_section = false;
+
+ for (i, line) in content.lines().enumerate() {
+ let trimmed = line.trim();
+
+ if trimmed.starts_with('[') && trimmed.ends_with(']') {
+ let header = &trimmed[1..trimmed.len() - 1];
+
+ if trimmed == section_header {
+ in_section = true;
+ continue;
+ }
+
+ if in_section {
+ // Child table like [rust-analyzer.check] stays within section
+ if header.starts_with(&format!("{section_name}.")) {
+ continue;
+ }
+ // Any other table means the section has ended
+ in_section = false;
+ continue;
+ }
+ continue;
+ }
+
+ if !in_section {
+ continue;
+ }
+
+ let without_comment = trimmed.split('#').next().unwrap_or("").trim();
+ if without_comment.is_empty() {
+ continue;
+ }
+
+ if let Some(eq_pos) = without_comment.find('=') {
+ let k = without_comment[..eq_pos].trim();
+ let v = without_comment[eq_pos + 1..].trim();
+
+ // Match both dotted key (check.overrideCommand) and bare field (overrideCommand)
+ if k == dotted_key || k == field {
+ return Some((i + 1, v.to_string()));
+ }
+ }
+ }
+
+ None
+}
+
+/// Find the 1-based line number of a TOML section header like `[section]`.
+fn find_section_header(content: &str, section_name: &str) -> Option<usize> {
+ let target = format!("[{section_name}]");
+ content
+ .lines()
+ .position(|line| line.trim() == target)
+ .map(|i| i + 1)
+}
+
+/// Find the last 1-based line number *within* a TOML section.
+///
+/// Returns the last content line (including blank lines) before the next
+/// sibling section begins, or `None` if `[section_name]` is not found.
+fn find_section_last_line(content: &str, section_name: &str) -> Option<usize> {
+ let section_header = format!("[{section_name}]");
+ let mut in_section = false;
+ let mut last = None;
+
+ for (i, line) in content.lines().enumerate() {
+ let trimmed = line.trim();
+
+ if trimmed.starts_with('[') && trimmed.ends_with(']') {
+ let header = &trimmed[1..trimmed.len() - 1];
+
+ if trimmed == section_header {
+ in_section = true;
+ continue;
+ }
+
+ if in_section {
+ if header.starts_with(&format!("{section_name}.")) {
+ // Child table, still in section
+ last = Some(i + 1);
+ continue;
+ }
+ // A sibling section means the end of the current section
+ return last;
+ }
+ continue;
+ }
+
+ if in_section {
+ last = Some(i + 1);
+ }
+ }
+
+ last
+}
+
+/// Find the TOML table header whose dotted path shares the longest common
+/// prefix with `expected_path`.
+///
+/// Returns `(line_number, matched_prefix, remaining_suffix)`:
+/// - `line_number`: 1-based line of the best-match header (0 if none)
+/// - `matched_prefix`: path segments that matched (e.g. `["rust-analyzer"]`)
+/// - `remaining_suffix`: path segments not yet matched (e.g. `["check"]`)
+fn find_longest_toml_header<'a>(
+ content: &str,
+ expected_path: &[&'a str],
+) -> (usize, Vec<&'a str>, Vec<&'a str>) {
+ let mut best_match_len = 0usize;
+ let mut best_line = 0usize;
+
+ for (i, line) in content.lines().enumerate() {
+ let trimmed = line.trim();
+ if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
+ continue;
+ }
+
+ let header = &trimmed[1..trimmed.len() - 1];
+ let header_parts: Vec<&str> = header.split('.').map(|s| s.trim()).collect();
+
+ // Count how many leading segments of header_parts match expected_path
+ let match_len = header_parts
+ .iter()
+ .zip(expected_path.iter())
+ .take_while(|(h, e)| *h == *e)
+ .count();
+
+ // Only consider matches that don't exceed expected_path
+ if match_len > best_match_len && match_len <= expected_path.len() {
+ best_match_len = match_len;
+ best_line = i + 1;
+ }
+ }
+
+ let matched = expected_path[..best_match_len].to_vec();
+ let remaining = expected_path[best_match_len..].to_vec();
+ (best_line, matched, remaining)
+}
+
+/// Decide the best insert position and content for adding `check.overrideCommand`
+/// inside a TOML section (typically `rust-analyzer`).
+///
+/// Scans all `[table]` headers matching the expected path `[section_name, "check"]`
+/// and picks the longest prefix match:
+/// - Full match (`[rust-analyzer.check]`) → bare key `overrideCommand = [...]` inside it.
+/// - Partial match (`[rust-analyzer]` only) → dotted key `check.overrideCommand = [...]` inside it.
+/// - No match → create the full table hierarchy at end of file.
+fn build_override_insert(content: &str, section_name: &str) -> (usize, String) {
+ // Full config line: check.overrideCommand = ["mling", "ra-lint-check"]
+ let value = r#"["mling", "ra-lint-check"]"#;
+
+ // Expected TOML path segments: e.g. ["rust-analyzer", "check"]
+ let expected_path: Vec<&str> = vec![section_name, "check"];
+ let dotted_key = "check.overrideCommand";
+ let bare_key = "overrideCommand";
+
+ let (_line, matched, remaining) = find_longest_toml_header(content, &expected_path);
+
+ if matched.is_empty() {
+ // No matching table at all — create full hierarchy at end
+ let total_lines = content.lines().count().max(1);
+ (
+ total_lines + 1,
+ format!("\n[{section_name}]\n{dotted_key} = {value}\n"),
+ )
+ } else if remaining.is_empty() {
+ // Full match — e.g. [rust-analyzer.check] exists, insert bare key
+ let table_name = matched.join(".");
+ let section_end = find_section_last_line(content, &table_name)
+ .unwrap_or_else(|| content.lines().count().max(1));
+ (section_end + 1, format!("{bare_key} = {value}\n"))
+ } else {
+ // Partial match — e.g. only [rust-analyzer] exists, insert dotted key
+ let table_name = matched.join(".");
+ let section_end = find_section_last_line(content, &table_name)
+ .unwrap_or_else(|| content.lines().count().max(1));
+ (section_end + 1, format!("{dotted_key} = {value}\n"))
+ }
+}
+
+/// Check a simple key=value pair inside a TOML section.
+fn check_simple_key_in_section(
content: &str,
key: &str,
expected_val: &str,
display_line: &str,
source_file: &str,
+ section_name: &str,
) -> Vec<MlintReport> {
- let found = find_key_value(content, key);
+ let found = find_key_in_section(content, key, section_name);
let matches = found
.as_ref()
@@ -193,12 +402,11 @@ fn check_simple_key(
return vec![];
}
- let msg = format!("expected `{display_line}` in `rust-analyzer.toml`");
+ let msg = format!("expected `{display_line}` in `[{section_name}]` in `rust-analyzer.toml`");
let report = report_help(source_file, content, msg);
match found {
Some((ln, val)) => {
- // Key exists but value is wrong → suggest replacing the value
let line_text = nth_line(content, ln);
let byte_start = line_text.find(&val).unwrap_or(0);
let byte_end = byte_start + val.len();
@@ -212,31 +420,33 @@ fn check_simple_key(
)]
}
None => {
- // Key missing entirely → suggest inserting line at end
- let insert_line = content.lines().count().max(1) + 1;
+ let insert_line = find_section_header(content, section_name)
+ .map(|h| h + 1)
+ .unwrap_or_else(|| content.lines().count().max(1) + 1);
let new_content = format!("{display_line}\n");
vec![with_insert_suggestion(report, insert_line, new_content)]
}
}
}
-fn check_override_command(content: &str, source_file: &str) -> Vec<MlintReport> {
+/// Check `check.overrideCommand` inside the given TOML section.
+fn check_override_command_in_section(
+ content: &str,
+ source_file: &str,
+ section_name: &str,
+) -> Vec<MlintReport> {
let mut reports = Vec::new();
- let Some((ln, val)) = find_key_value(content, OVERRIDE_KEY) else {
- // Setting entirely missing
+ let Some((ln, val)) = find_key_in_section(content, OVERRIDE_KEY, section_name) else {
+ // Setting entirely missing — build smart insert suggestion
let report = report_help(
source_file,
content,
- format!("expected `{EXPECTED_LINE}` in `rust-analyzer.toml`"),
+ format!("expected `{EXPECTED_LINE}` in `[{section_name}]` in `rust-analyzer.toml`"),
);
- let (insert_line, new_content) = match find_table_header(content, "check") {
- Some(header_line) => (header_line + 1, SUGGEST_OVERRIDE_LINE.to_string()),
- None => (
- content.lines().count().max(1) + 1,
- format!("{SUGGEST_OVERRIDE_FULL_LINE}\n"),
- ),
- };
+
+ let (insert_line, new_content) = build_override_insert(content, section_name);
+
reports.push(with_insert_suggestion(report, insert_line, new_content));
return reports;
};
@@ -365,69 +575,6 @@ fn parse_array_items(s: &str) -> Vec<String> {
items
}
-/// Result of looking up a key=value pair in TOML content.
-///
-/// - `Some((line, value))` — found on that 1-based line with that value string.
-/// - `None` — key not found.
-type FoundKey = Option<(usize, String)>;
-
-fn find_key_value(content: &str, dotted_key: &str) -> FoundKey {
- let parts: Vec<&str> = dotted_key.split('.').collect();
- let field = parts.last().copied().unwrap_or(dotted_key);
- let table_path = if parts.len() > 1 {
- &parts[..parts.len() - 1]
- } else {
- &[]
- };
- let table_path_str = if parts.len() > 1 {
- Some(parts[..parts.len() - 1].join("."))
- } else {
- None
- };
-
- let mut in_correct_table = table_path.is_empty();
-
- for (i, line) in content.lines().enumerate() {
- let trimmed = line.trim();
-
- // Track table headers like [check]
- if trimmed.starts_with('[') && trimmed.ends_with(']') {
- let header = &trimmed[1..trimmed.len() - 1];
- in_correct_table = header.split('.').collect::<Vec<_>>() == table_path;
- continue;
- }
-
- let without_comment = trimmed.split('#').next().unwrap_or("").trim();
- if without_comment.is_empty() {
- continue;
- }
-
- if let Some(eq_pos) = without_comment.find('=') {
- let k = without_comment[..eq_pos].trim();
- let v = without_comment[eq_pos + 1..].trim();
-
- // Inside explicit [table] header
- if in_correct_table && k == field {
- return Some((i + 1, v.to_string()));
- }
- // Inline dotted key at root (e.g. `check.overrideCommand = ...`)
- if table_path_str.is_some() && k == dotted_key {
- return Some((i + 1, v.to_string()));
- }
- }
- }
-
- None
-}
-
-fn find_table_header(content: &str, table_name: &str) -> Option<usize> {
- let target = format!("[{table_name}]");
- content
- .lines()
- .position(|line| line.trim() == target)
- .map(|i| i + 1)
-}
-
fn nth_line(content: &str, n: usize) -> String {
content
.lines()