diff options
Diffstat (limited to 'mingling_cli/src/linter')
| -rw-r--r-- | mingling_cli/src/linter/cmd_mlint_install.rs | 323 |
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() |
