From 29009ea248ddf70698cdf90fa36b9bbb89442668 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Wed, 22 Jul 2026 22:57:35 +0800 Subject: feat(linter): add `lint-install` subcommand for rust-analyzer setup --- mingling_cli/Cargo.lock | 69 ++++ mingling_cli/Cargo.toml | 1 + mingling_cli/src/errors.rs | 2 + mingling_cli/src/errors/io_error.rs | 8 + mingling_cli/src/linter.rs | 18 +- mingling_cli/src/linter/cmd_mlint.rs | 4 +- mingling_cli/src/linter/cmd_mlint_install.rs | 454 +++++++++++++++++++++++++++ mingling_cli/tmpls/rust-analyzer.toml | 9 + 8 files changed, 555 insertions(+), 10 deletions(-) create mode 100644 mingling_cli/src/errors/io_error.rs create mode 100644 mingling_cli/src/linter/cmd_mlint_install.rs create mode 100644 mingling_cli/tmpls/rust-analyzer.toml diff --git a/mingling_cli/Cargo.lock b/mingling_cli/Cargo.lock index e617d0a..ae28d9a 100644 --- a/mingling_cli/Cargo.lock +++ b/mingling_cli/Cargo.lock @@ -154,6 +154,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -170,12 +176,28 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.18" @@ -259,6 +281,7 @@ dependencies = [ "serde_json", "syn 3.0.2", "tokio", + "toml_edit", ] [[package]] @@ -516,6 +539,43 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -549,6 +609,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/mingling_cli/Cargo.toml b/mingling_cli/Cargo.toml index 98833ff..906cdd0 100644 --- a/mingling_cli/Cargo.toml +++ b/mingling_cli/Cargo.toml @@ -52,6 +52,7 @@ serde_json = "1.0.151" # Parallelism tokio = { version = "1.53.1", features = ["full"] } +toml_edit = "0.25.13" [build-dependencies] # Configure & Serialization diff --git a/mingling_cli/src/errors.rs b/mingling_cli/src/errors.rs index cef9616..5cdc43f 100644 --- a/mingling_cli/src/errors.rs +++ b/mingling_cli/src/errors.rs @@ -1 +1,3 @@ +pub mod io_error; pub mod serde_json; + diff --git a/mingling_cli/src/errors/io_error.rs b/mingling_cli/src/errors/io_error.rs new file mode 100644 index 0000000..97509c9 --- /dev/null +++ b/mingling_cli/src/errors/io_error.rs @@ -0,0 +1,8 @@ +use mingling::macros::{group, renderer}; + +group!(ErrorIo = std::io::Error); + +#[renderer] +pub fn handle_error_io(err: ErrorIo) { + panic!("{}", err.to_string()) +} diff --git a/mingling_cli/src/linter.rs b/mingling_cli/src/linter.rs index c85afc1..5d20900 100644 --- a/mingling_cli/src/linter.rs +++ b/mingling_cli/src/linter.rs @@ -4,11 +4,15 @@ use mingling::{ }; use crate::{ - linter::cmd_mlint::{CMDMinglingLinter, EntryMinglingLinter}, + linter::{ + cmd_mlint::{CMDLint, EntryLint}, + cmd_mlint_install::CMDLintInstall, + }, metadata::setup::ResUsingJson, }; pub mod cmd_mlint; +pub mod cmd_mlint_install; pub mod mlint_attr; pub mod mlint_report; @@ -19,7 +23,8 @@ pub fn mingling_linter_setup(program: &mut Program) { #[program_setup] pub fn mingling_linter_command_setup(program: &mut Program) { - program.with_dispatcher(CMDMinglingLinter); + program.with_dispatcher(CMDLint); + program.with_dispatcher(CMDLintInstall); program.with_dispatcher(CMDLinterSupportRustAnalyzer); program.with_dispatcher(CMDLinterSupportRustAnalyzerWithClippy); program.with_dispatcher(CMDLinterSupportRustAnalyzerWithCheck); @@ -40,10 +45,7 @@ dispatcher!("ra-lint", ); #[chain] -pub fn handle_ra_lint( - _: EntryLinterSupportRustAnalyzer, - use_json: &mut ResUsingJson, -) -> EntryMinglingLinter { +pub fn handle_ra_lint(_: EntryLinterSupportRustAnalyzer, use_json: &mut ResUsingJson) -> EntryLint { use_json.using = true; entry!("--message-format=json") } @@ -52,7 +54,7 @@ pub fn handle_ra_lint( pub fn handle_ra_lint_check( _: EntryLinterSupportRustAnalyzerWithCheck, use_json: &mut ResUsingJson, -) -> EntryMinglingLinter { +) -> EntryLint { use_json.using = true; entry!("--message-format=json", "--with-checker=cargo,check") } @@ -61,7 +63,7 @@ pub fn handle_ra_lint_check( pub fn handle_ra_lint_clippy( _: EntryLinterSupportRustAnalyzerWithClippy, use_json: &mut ResUsingJson, -) -> EntryMinglingLinter { +) -> EntryLint { use_json.using = true; entry!("--message-format=json", "--with-checker=cargo,clippy") } diff --git a/mingling_cli/src/linter/cmd_mlint.rs b/mingling_cli/src/linter/cmd_mlint.rs index 55dd15c..1dfd02b 100644 --- a/mingling_cli/src/linter/cmd_mlint.rs +++ b/mingling_cli/src/linter/cmd_mlint.rs @@ -6,7 +6,7 @@ use mingling::macros::{arg, chain, dispatcher, pack}; use mingling::picker::EntryPicker; use tokio::task::JoinSet; -dispatcher!("lint", CMDMinglingLinter => EntryMinglingLinter); +dispatcher!("lint", CMDLint => EntryLint); /// Main linting function that processes all packages in the metadata. /// @@ -71,7 +71,7 @@ async fn linter_main(metadata: &Metadata) -> Vec { pack!(StateBeginLinter = ()); #[chain] -pub fn handle_lint(args: EntryMinglingLinter) -> StateBeginLinter { +pub fn handle_lint(args: EntryLint) -> StateBeginLinter { let (with_checker, checker_args) = args .pick_or(&arg![with_checker: Option], || { Some("cargo,check".to_string()) diff --git a/mingling_cli/src/linter/cmd_mlint_install.rs b/mingling_cli/src/linter/cmd_mlint_install.rs new file mode 100644 index 0000000..d29d3e5 --- /dev/null +++ b/mingling_cli/src/linter/cmd_mlint_install.rs @@ -0,0 +1,454 @@ +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; +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"); + +// 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"; +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\"]"; +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"; +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(CFG_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, +) -> StateLintReports { + let cfg_file_path = current_dir.join(CFG_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( + &content, + KEY_CHECK_ON_SAVE, + VALUE_CHECK_ON_SAVE_TRUE, + DISPLAY_CHECK_ON_SAVE_TRUE, + SOURCE_FILE_NAME, + )); + reports.extend(check_override_command(&content, SOURCE_FILE_NAME)); + + 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 + } +} + +fn check_simple_key( + content: &str, + key: &str, + expected_val: &str, + display_line: &str, + source_file: &str, +) -> Vec { + let found = find_key_value(content, key); + + 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 `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(); + vec![with_replace_suggestion( + report, + ln, + &line_text, + byte_start..byte_end, + expected_val.to_string(), + Some(format!("expected {expected_val}")), + )] + } + None => { + // Key missing entirely → suggest inserting line at end + let insert_line = 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 { + let mut reports = Vec::new(); + + let Some((ln, val)) = find_key_value(content, OVERRIDE_KEY) else { + // Setting entirely missing + let report = report_help( + source_file, + content, + format!("expected `{EXPECTED_LINE}` 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"), + ), + }; + 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 +} + +/// 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::>() == 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 { + 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() + .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() +} diff --git a/mingling_cli/tmpls/rust-analyzer.toml b/mingling_cli/tmpls/rust-analyzer.toml new file mode 100644 index 0000000..96a96b4 --- /dev/null +++ b/mingling_cli/tmpls/rust-analyzer.toml @@ -0,0 +1,9 @@ +checkOnSave = true + +# Use mling with ra-lint-check for code checking +# +# This tool will: +# 1. first run `cargo check` to perform basic checks on your workspace +# 2. then run `mling lint` to perform additional checks +# on the `Mingling` framework code in your workspace +check.overrideCommand = ["mling", "ra-lint-check"] -- cgit