use crate::Next; use crate::linter::mlint_report::{ LintSpan, LintSpanLine, LintSuggestion, MlintLevel, MlintReport, StateLintReports, }; use mingling::Routable; use mingling::macros::{buffer, chain, dispatcher, pack, r_eprintln, renderer, routeify}; use mingling::res::{ResCurrentDir, ResExitCode}; use std::ops::Range; use std::path::PathBuf; const OVERRIDE_KEY: &str = "check.overrideCommand"; 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 SOURCE_FILE_NAME: &str = "rust-analyzer.toml"; const MSG_ALREADY_CORRECT: &str = "`.rust-analyzer.toml` already has the correct mling settings"; const MSG_NON_EMPTY_ARRAY: &str = "`check.overrideCommand`: expected a non-empty array"; const MSG_FIRST_ARG_INVALID: &str = "`check.overrideCommand`: first argument should be `mling` or `mingling-cli`"; const MSG_MISSING_SECOND: &str = "`check.overrideCommand`: missing second argument"; const MSG_SECOND_ARG_INVALID: &str = "`check.overrideCommand`: second argument should be a `ra-lint-*` subcommand or `lint`"; const MSG_MESSAGE_FORMAT_REQUIRED: &str = "`check.overrideCommand`: `lint` subcommand needs `--message-format=json`"; // Suggestions / replacements 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\"]"; // Subcommand constants const SUB_CMD_LINT: &str = "lint"; const MESSAGE_FORMAT_FLAG: &str = "--message-format=json"; dispatcher!("lint-install", CMDLintInstall => EntryLintInstall); pack!(StateWriteMlingLinterConfig = PathBuf); pack!(StateSuggestMlingLinterSetup = ()); pack!(ResultMlingLinterConfigInstalled = PathBuf); #[chain] pub fn handle_lint_install(_: EntryLintInstall, current_dir: &ResCurrentDir) -> Next { let cfg_file_path = current_dir.join(SOURCE_FILE_NAME); if !cfg_file_path.exists() { return StateWriteMlingLinterConfig::new(cfg_file_path).to_chain(); } StateSuggestMlingLinterSetup::new(()).to_chain() } #[chain(routeify)] pub fn handle_state_write_mling_linter_config(prev: StateWriteMlingLinterConfig) -> Next { let cfg_file_path = prev.inner; std::fs::write(&cfg_file_path, RA_CONFIG_TEMPLATE)?; ResultMlingLinterConfigInstalled::new(cfg_file_path).into() } #[renderer(buffer)] pub fn render_mling_linter_config_installed(result: ResultMlingLinterConfigInstalled) { let cfg_file_path = result.inner; r_eprintln!( "info: created `{}` with mling lint-integrated settings", cfg_file_path.display() ); } #[chain] pub fn handle_state_suggest_mling_linter_setup( _: StateSuggestMlingLinterSetup, current_dir: &ResCurrentDir, ec: &mut ResExitCode, ) -> StateLintReports { 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) { Ok(c) => c, Err(e) => { return StateLintReports::new(vec![MlintReport { level: MlintLevel::Error, message: format!("failed to read `{file_name}`: {e}"), ..Default::default() }]); } }; let mut reports: Vec = vec![]; 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, )); if reports.is_empty() { reports.push(MlintReport { level: MlintLevel::Note, message: MSG_ALREADY_CORRECT.to_string(), ..Default::default() }); } StateLintReports::new(reports) } /// A `MlintReport` at `Help` level with the given message, file, and source. fn report_help(file_name: &str, source_code: &str, message: String) -> MlintReport { MlintReport { file_name: file_name.to_string(), source_code: source_code.to_string(), level: MlintLevel::Help, message, ..Default::default() } } /// Attach a single-line span + replace suggestion to a report. fn with_replace_suggestion( report: MlintReport, line: usize, line_text: &str, byte_range: Range, replacement: String, label: Option, ) -> MlintReport { let span = LintSpan { line_start: line, line_end: line, column_start: byte_range.start + 1, column_end: byte_range.end + 1, text: vec![LintSpanLine { text: line_text.to_string(), highlight_start: byte_range.start + 1, highlight_end: byte_range.end + 1, }], label, }; let suggestion = LintSuggestion { source: line_text.to_string(), line_start: line, byte_range, replacement, }; MlintReport { spans: vec![span], suggestions: vec![suggestion], ..report } } /// Attach an "insert new content" suggestion (byte_range 0..0) to a report. fn with_insert_suggestion(report: MlintReport, line: usize, new_content: String) -> MlintReport { let suggestion = LintSuggestion { source: new_content.clone(), line_start: line, byte_range: 0..0, replacement: new_content, }; MlintReport { suggestions: vec![suggestion], ..report } } /// 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 { 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 { 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 { let found = find_key_in_section(content, key, section_name); let matches = found .as_ref() .is_some_and(|(_, v)| collapse_whitespace(v) == collapse_whitespace(expected_val)); if matches { return vec![]; } 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)) => { let line_text = nth_line(content, ln); let byte_start = line_text.find(&val).unwrap_or(0); let byte_end = byte_start + val.len(); vec![with_replace_suggestion( report, ln, &line_text, byte_start..byte_end, expected_val.to_string(), Some(format!("expected {expected_val}")), )] } None => { 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)] } } } /// Check `check.overrideCommand` inside the given TOML section. fn check_override_command_in_section( content: &str, source_file: &str, section_name: &str, ) -> Vec { let mut reports = Vec::new(); 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 `[{section_name}]` in `rust-analyzer.toml`"), ); let (insert_line, new_content) = build_override_insert(content, section_name); reports.push(with_insert_suggestion(report, insert_line, new_content)); return reports; }; let line_text = nth_line(content, ln); let args = parse_array_items(&val); // First: must be `mling` or `mingling-cli` if !args .first() .is_some_and(|a| VALID_FIRST.contains(&a.as_str())) { let Some(first) = args.first() else { let report = report_help(source_file, content, MSG_NON_EMPTY_ARRAY.into()); reports.push(with_replace_suggestion( report, ln, &line_text, 0..val.len(), SUGGEST_RA_LINT_CHECK_ARRAY.into(), None, )); return reports; }; let quoted = format!("\"{first}\""); let byte_start = line_text.find("ed).unwrap_or(0); let byte_end = byte_start + quoted.len(); let report = report_help(source_file, content, MSG_FIRST_ARG_INVALID.into()); reports.push(with_replace_suggestion( report, ln, &line_text, byte_start..byte_end, SUGGEST_MLING_QUOTED.into(), None, )); return reports; } // Second: must be `ra-lint-*` or `lint` let Some(second) = args.get(1) else { let report = report_help(source_file, content, MSG_MISSING_SECOND.into()); reports.push(with_replace_suggestion( report, ln, &line_text, 0..val.len(), SUGGEST_RA_LINT_CHECK_ARRAY.into(), None, )); return reports; }; if !second.starts_with("ra-lint-") && second != SUB_CMD_LINT { let quoted = format!("\"{second}\""); let byte_start = line_text.find("ed).unwrap_or(0); let byte_end = byte_start + quoted.len(); let report = report_help(source_file, content, MSG_SECOND_ARG_INVALID.into()); reports.push(with_replace_suggestion( report, ln, &line_text, byte_start..byte_end, SUGGEST_RA_LINT_CHECK_QUOTED.into(), None, )); return reports; } // If second arg is `lint`, it must be followed by --message-format=json if second == SUB_CMD_LINT && !has_message_format_json(&args[2..]) { let byte_start = line_text .rfind(']') .unwrap_or(line_text.len().saturating_sub(1)); let byte_end = byte_start + 1; let report = report_help(source_file, content, MSG_MESSAGE_FORMAT_REQUIRED.into()); reports.push(with_replace_suggestion( report, ln, &line_text, byte_start..byte_end, SUGGEST_MESSAGE_FORMAT_JSON.into(), None, )); } reports } fn has_message_format_json(rest: &[String]) -> bool { rest.contains(&MESSAGE_FORMAT_FLAG.to_string()) || rest .windows(2) .any(|w| w[0] == "--message-format" && w[1] == "json") } fn parse_array_items(s: &str) -> Vec { let s = s.trim(); if !s.starts_with('[') || !s.ends_with(']') { return vec![]; } let inner = s[1..s.len() - 1].trim(); if inner.is_empty() { return vec![]; } let mut items = Vec::new(); let mut current = String::new(); let mut in_quote = false; for ch in inner.chars() { match ch { '"' => in_quote = !in_quote, ',' if !in_quote => { let trimmed = current.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { items.push(trimmed); } current.clear(); } _ => current.push(ch), } } let trimmed = current.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { items.push(trimmed); } items } fn nth_line(content: &str, n: usize) -> String { content .lines() .nth(n.saturating_sub(1)) .unwrap_or("") .to_string() } fn collapse_whitespace(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut in_space = false; for ch in s.chars() { if ch.is_whitespace() { if !in_space { out.push(' '); in_space = true; } } else { out.push(ch); in_space = false; } } out.trim().to_string() }