aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mingling_cli/Cargo.lock69
-rw-r--r--mingling_cli/Cargo.toml1
-rw-r--r--mingling_cli/src/errors.rs2
-rw-r--r--mingling_cli/src/errors/io_error.rs8
-rw-r--r--mingling_cli/src/linter.rs18
-rw-r--r--mingling_cli/src/linter/cmd_mlint.rs4
-rw-r--r--mingling_cli/src/linter/cmd_mlint_install.rs454
-rw-r--r--mingling_cli/tmpls/rust-analyzer.toml9
8 files changed, 555 insertions, 10 deletions
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
@@ -155,6 +155,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -171,12 +177,28 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -259,6 +281,7 @@ dependencies = [
"serde_json",
"syn 3.0.2",
"tokio",
+ "toml_edit",
]
[[package]]
@@ -517,6 +540,43 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -550,6 +610,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
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<crate::ThisProgram>) {
#[program_setup]
pub fn mingling_linter_command_setup(program: &mut Program<crate::ThisProgram>) {
- 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<MlintReport> {
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<String>], || {
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<MlintReport> = 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<usize>,
+ replacement: String,
+ label: Option<String>,
+) -> 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<MlintReport> {
+ 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<MlintReport> {
+ 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(&quoted).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(&quoted).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<String> {
+ 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::<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()
+ .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"]