diff options
| -rw-r--r-- | mingling_cli/build.rs | 2 | ||||
| -rw-r--r-- | mingling_cli/pre/lint_registry.rs | 16 | ||||
| -rw-r--r-- | mingling_cli/src/linter/mlint_report.rs | 40 | ||||
| -rw-r--r-- | mingling_cli/src/lints.rs | 26 | ||||
| -rw-r--r-- | mingling_cli/src/lints/non_mingling_naming_style.rs | 351 | ||||
| -rw-r--r-- | mingling_cli/tmpls/lints.tmpl | 45 |
6 files changed, 454 insertions, 26 deletions
diff --git a/mingling_cli/build.rs b/mingling_cli/build.rs index b74d8a0..ec4f208 100644 --- a/mingling_cli/build.rs +++ b/mingling_cli/build.rs @@ -4,7 +4,7 @@ pub mod pre; fn main() { // Perform path analysis and build type mapping table - analyze_and_build_type_mapping().unwrap(); + analyze_and_build_type_mapping().ok(); // Generate lint registry pre::gen_mod_file().unwrap(); diff --git a/mingling_cli/pre/lint_registry.rs b/mingling_cli/pre/lint_registry.rs index 09d67f9..e69592b 100644 --- a/mingling_cli/pre/lint_registry.rs +++ b/mingling_cli/pre/lint_registry.rs @@ -43,6 +43,22 @@ pub fn gen_mod_file() -> Result<(), Error> { }) } + // Generate file-level lint calls (lints that have `pub fn check_file`) + for name in &entries { + let file_path = lints_dir.join(format!("{name}.rs")); + let has_check_file = fs::read_to_string(&file_path) + .map(|c| c.contains("pub fn check_file(")) + .unwrap_or(false); + if has_check_file { + tmpl!(template, file_calls { + name = name + }); + tmpl!(template, file_lints { + name = name + }); + } + } + fs::write(&mod_file, template.to_string())?; Ok(()) diff --git a/mingling_cli/src/linter/mlint_report.rs b/mingling_cli/src/linter/mlint_report.rs index 07fc5c1..8195f77 100644 --- a/mingling_cli/src/linter/mlint_report.rs +++ b/mingling_cli/src/linter/mlint_report.rs @@ -1,3 +1,5 @@ +use std::ops::Range; + use cargo_metadata::diagnostic::{ DiagnosticCodeBuilder, DiagnosticLevel as CargoLevel, DiagnosticSpanBuilder, DiagnosticSpanLineBuilder, @@ -6,7 +8,7 @@ use cargo_metadata::diagnostic::{ use cargo_metadata::{Message, PackageId}; use annotate_snippets::level::{ERROR, HELP, NOTE, WARNING}; -use annotate_snippets::{AnnotationKind, Group, Renderer, Snippet}; +use annotate_snippets::{AnnotationKind, Group, Patch, Renderer, Snippet}; use mingling::macros::{buffer, chain, pack, r_append, r_eprintln, renderer}; use mingling::{AnyOutput, ProgramCollect, Routable}; @@ -48,6 +50,9 @@ pub struct MlintReport { /// Compilation target source file path that this report belongs to pub target_src_path: Option<String>, + + /// A suggestion for automatic fix (shown as diff in annotated output) + pub suggestion: Option<LintSuggestion>, } /// Report severity level, indicating the seriousness of the Lint result. @@ -86,6 +91,19 @@ pub struct LintSpanLine { pub highlight_end: usize, } +/// A suggestion shown as a diff in the output (e.g. `- old code` / `+ new code`). +#[derive(Clone, Debug, Default)] +pub struct LintSuggestion { + /// Source text that the suggestion applies to (a single line or snippet) + pub source: String, + /// Line number where the suggestion applies + pub line_start: usize, + /// Byte range within `source` to replace + pub byte_range: Range<usize>, + /// Replacement text + pub replacement: String, +} + impl MlintReport { /// Build a `LintSpan` from a syn spanned item and the source text. pub fn span_from_syn<T: syn::spanned::Spanned>(value: &T, source: &str) -> LintSpan { @@ -230,6 +248,18 @@ impl MlintReport { group = group.element(msg); } + // Render suggestion as a diff (Element::Suggestion) + if let Some(sugg) = &self.suggestion { + let patch_snippet = Snippet::source(sugg.source.clone()) + .line_start(sugg.line_start) + .path(&self.file_name) + .patch(Patch::new( + sugg.byte_range.clone(), + sugg.replacement.clone(), + )); + group = group.element(patch_snippet); + } + if !self.lint_code.is_empty() { let level_name = match self.level { MlintLevel::Error => "deny", @@ -374,8 +404,12 @@ impl MlintReport { .collect::<Vec<_>>(), ) .label(span.label.clone()) - .suggested_replacement(None::<String>) - .suggestion_applicability(None::<cargo_metadata::diagnostic::Applicability>) + .suggested_replacement(self.suggestion.as_ref().map(|s| s.replacement.clone())) + .suggestion_applicability(if self.suggestion.is_some() { + Some(cargo_metadata::diagnostic::Applicability::MachineApplicable) + } else { + None + }) .expansion(None) .build() .unwrap() diff --git a/mingling_cli/src/lints.rs b/mingling_cli/src/lints.rs index 1db4c6b..89f5ca3 100644 --- a/mingling_cli/src/lints.rs +++ b/mingling_cli/src/lints.rs @@ -1,17 +1,33 @@ #![allow(unused)] use crate::linter::mlint_report::{MlintLevel, MlintReport}; +mod non_mingling_naming_style; +pub use non_mingling_naming_style::linter as non_mingling_naming_style; mod template_linter; pub use template_linter::linter as template_linter; mod unnecessary_render_result_creation; pub use unnecessary_render_result_creation::linter as unnecessary_render_result_creation; +pub use non_mingling_naming_style::check_file as non_mingling_naming_style_file; /// Run all registered lints on a parsed file with its source text. pub fn run_all_lints(file: &syn::File, source: &str) -> Vec<MlintReport> { use crate::linter::mlint_attr::{get_mlint_override, MlintLevelOverride}; - let mut reports = vec![]; + + // File-level lints (check types, modules, etc.) + let mut reports: Vec<MlintReport> = vec![]; + reports.extend(non_mingling_naming_style::check_file(file, source)); + + // Item-level lints (check each function) for item in &file.items { if let syn::Item::Fn(f) = item { + let skip = get_mlint_override(&f.attrs, "non_mingling_naming_style") == Some(MlintLevelOverride::Allow); + if !skip { + let mut rs = non_mingling_naming_style::linter(f.clone(), source); + if get_mlint_override(&f.attrs, "non_mingling_naming_style") == Some(MlintLevelOverride::Deny) { + for r in &mut rs { r.level = MlintLevel::Error; } + } + reports.extend(rs); + } let skip = get_mlint_override(&f.attrs, "template_linter") == Some(MlintLevelOverride::Allow); if !skip { let mut rs = template_linter::linter(f.clone(), source); @@ -28,12 +44,12 @@ pub fn run_all_lints(file: &syn::File, source: &str) -> Vec<MlintReport> { } reports.extend(rs); } - } } + + // Apply file-level #![mlint(allow/warn/deny(...))] overrides for r in &mut reports { - let name = &r.lint_code; - if let Some(override_level) = get_mlint_override(&file.attrs, name) { + if let Some(override_level) = get_mlint_override(&file.attrs, &r.lint_code) { match override_level { MlintLevelOverride::Allow => r.level = MlintLevel::Help, MlintLevelOverride::Deny => r.level = MlintLevel::Error, @@ -50,8 +66,6 @@ pub fn run_all_lints(file: &syn::File, source: &str) -> Vec<MlintReport> { #[macro_export] macro_rules! assert_detected { ($linter:expr, $ast_type:ty => { $($code:tt)* }) => { - // $($code:tt)* captures tokens INSIDE the braces, not including the braces - // e.g. `fn foo() { ... }` — exactly what syn::ItemFn expects let source = stringify!($($code)*); let ast: $ast_type = syn::parse_str(&source).unwrap(); assert!(!$linter(ast, &source).is_empty()); diff --git a/mingling_cli/src/lints/non_mingling_naming_style.rs b/mingling_cli/src/lints/non_mingling_naming_style.rs new file mode 100644 index 0000000..3340dd2 --- /dev/null +++ b/mingling_cli/src/lints/non_mingling_naming_style.rs @@ -0,0 +1,351 @@ +//! Non-Mingling Naming Style +//! +//! ## Summary +//! +//! Checks that Mingling functions follow naming conventions: +//! +//! | Prefix | 1st param must be | +//! |--------|------------------| +//! | `handle_` | `Entry*` | +//! | `handle_state_` | `State*` | +//! | `handle_error_` | `Error*` | +//! | `help_` | `Entry*` | +//! | `render_` | `Result*` | +//! | `render_error_` | `Error*` | +//! +//! The name after prefix (snake_case) must match the type after prefix (PascalCase). +//! +//! ## Metadata +//! +//! Author: `Weicao-CatilGrass` +//! Default: `warn` + +use crate::linter::mlint_report::{LintSuggestion, MlintLevel, MlintReport}; +use quote::ToTokens; +use syn::spanned::Spanned; + +/// File-level entry (placeholder). +pub fn check_file(_file: &syn::File, _source: &str) -> Vec<MlintReport> { + vec![] +} + +/// ItemFn entry. +pub fn linter(ast: syn::ItemFn, source: &str) -> Vec<MlintReport> { + check_fn_name(&ast, source) +} + +fn check_fn_name(func: &syn::ItemFn, source: &str) -> Vec<MlintReport> { + // Only check functions with Mingling attributes + let has_mingling_attr = func.attrs.iter().any(|a| { + let name = a.path().to_token_stream().to_string(); + name.ends_with("renderer") + || name.ends_with("chain") + || name.ends_with("help") + || name.ends_with("completion") + }); + if !has_mingling_attr { + return vec![]; + } + + let name = func.sig.ident.to_string(); + let first_param = func.sig.inputs.first(); + + let rule: Option<(&str, &str)> = { + if name.starts_with("handle_state_") { + Some(("handle_state_", "State")) + } else if name.starts_with("handle_error_") { + Some(("handle_error_", "Error")) + } else if name.starts_with("handle_") { + Some(("handle_", "Entry")) + } else if name.starts_with("render_error_") { + Some(("render_error_", "Error")) + } else if name.starts_with("render_") { + Some(("render_", "Result")) + } else if name.starts_with("help_") { + Some(("help_", "Entry")) + } else { + None + } + }; + + let Some((prefix, expected_prefix)) = rule else { + return vec![]; + }; + let fn_rest = &name[prefix.len()..]; + + let Some(first) = first_param else { + return vec![MlintReport { + level: MlintLevel::Warning, + lint_code: "non_mingling_naming_style".into(), + message: format!( + "`{name}` should take `{expected_prefix}*` as its first parameter, but it has no parameters" + ), + ..Default::default() + }]; + }; + + let type_name = param_type_name(first); + + if !type_name.starts_with(expected_prefix) || type_name.len() <= expected_prefix.len() { + let expected_type = format!("{expected_prefix}{}", snake_to_pascal(fn_rest)); + return vec![MlintReport { + level: MlintLevel::Warning, + lint_code: "non_mingling_naming_style".into(), + message: format!( + "`{name}` should take `{expected_prefix}*`, but got `{type_name}` — rename it to `{expected_type}`" + ), + spans: vec![MlintReport::span_from_syn(&func.sig.ident, source)], + ..Default::default() + }]; + } + + let type_rest = &type_name[expected_prefix.len()..]; + let fn_rest_normalized = snake_to_pascal(fn_rest); + + if type_rest != fn_rest_normalized { + let expected_type = format!("{expected_prefix}{fn_rest_normalized}"); + let expected_fn = format!("{prefix}{}", pascal_to_snake(type_rest)); + + // Heuristic: if the type's base name is a substring of fn_rest, the type is clean → rename fn + let rename_fn = fn_rest.to_lowercase().contains(&type_rest.to_lowercase()) + && fn_rest.to_lowercase() != type_rest.to_lowercase(); + + let (msg, span) = if rename_fn { + ( + format!( + "naming mismatch: rename `{name}` to `{expected_fn}` to match type `{type_name}`" + ), + MlintReport::span_from_syn(&func.sig.ident, source), + ) + } else { + ( + format!( + "naming mismatch: rename type `{type_name}` to `{expected_type}` to match function `{name}`" + ), + first_param_type_span(first, source), + ) + }; + + // Build diff suggestion + let source_line = source + .lines() + .nth(func.sig.span().start().line.saturating_sub(1)) + .unwrap_or(""); + let (byte_range_start, suggestion_target) = if rename_fn { + // Find the old function name in the source line + let pos = source_line.find(&name).unwrap_or(0); + (pos, expected_fn.clone()) + } else { + // Find the old type name in the source line + let pos = source_line.find(&type_name).unwrap_or(0); + (pos, expected_type.clone()) + }; + let byte_range = byte_range_start + ..byte_range_start + + if rename_fn { + name.len() + } else { + type_name.len() + }; + + return vec![MlintReport { + level: MlintLevel::Warning, + lint_code: "non_mingling_naming_style".into(), + message: msg, + spans: vec![span], + suggestion: Some(LintSuggestion { + source: source_line.to_string(), + line_start: func.sig.span().start().line, + byte_range, + replacement: suggestion_target, + }), + attached_reports: vec![MlintReport { + level: MlintLevel::Help, + message: format!("expected `{expected_fn}` ↔ `{expected_type}`"), + ..Default::default() + }], + ..Default::default() + }]; + } + + vec![] +} + +fn param_type_name(arg: &syn::FnArg) -> String { + if let syn::FnArg::Typed(pat) = arg + && let syn::Type::Path(ref tp) = *pat.ty.clone() + { + return tp + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect::<Vec<_>>() + .join("::"); + } + String::new() +} + +fn first_param_type_span(arg: &syn::FnArg, source: &str) -> crate::linter::mlint_report::LintSpan { + if let syn::FnArg::Typed(pat) = arg { + return MlintReport::span_from_syn(&*pat.ty, source); + } + MlintReport::span_from_syn(arg, source) +} + +fn snake_to_pascal(s: &str) -> String { + let mut r = String::new(); + let mut cap = true; + for ch in s.chars() { + if ch == '_' { + cap = true; + } else if cap { + r.extend(ch.to_uppercase()); + cap = false; + } else { + r.push(ch); + } + } + r +} + +fn pascal_to_snake(s: &str) -> String { + let mut r = String::new(); + for (i, ch) in s.char_indices() { + if ch.is_uppercase() && i != 0 { + r.push('_'); + } + for lower in ch.to_lowercase() { + r.push(lower); + } + } + r +} + +#[cfg(test)] +mod lint_test { + use crate::{assert_detected, assert_not_detected}; + + #[test] + fn handle_entry() { + assert_not_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::chain] + fn handle_greet(args: EntryGreet) {} + }); + } + + #[test] + fn handle_state() { + assert_not_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::chain] + fn handle_state_processing(prev: StateProcessing) {} + }); + } + + #[test] + fn handle_error() { + assert_not_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::chain] + fn handle_error_not_found(err: ErrorNotFound) {} + }); + } + + #[test] + fn render_result() { + assert_not_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::renderer] + fn render_greeting(result: ResultGreeting) {} + }); + } + + #[test] + fn render_error() { + assert_not_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::renderer] + fn render_error_not_found(err: ErrorNotFound) {} + }); + } + + #[test] + fn help_entry() { + assert_not_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::help] + fn help_greet(args: EntryGreet) {} + }); + } + + #[test] + fn handle_should_be_entry() { + assert_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::chain] + fn handle_greet(x: String) {} + }); + } + + #[test] + fn handle_state_should_be_state() { + assert_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::chain] + fn handle_state_processing(x: String) {} + }); + } + + #[test] + fn handle_error_should_be_error() { + assert_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::chain] + fn handle_error_not_found(x: String) {} + }); + } + + #[test] + fn render_should_be_result() { + assert_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::renderer] + fn render_greeting(x: EntryGreet) {} + }); + } + + #[test] + fn render_error_should_be_error() { + assert_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::renderer] + fn render_error_not_found(x: ResultGreeting) {} + }); + } + + #[test] + fn name_mismatch_rename_fn() { + assert_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::renderer] + fn render_result_greeting(_greeting: ResultGreeting) {} + }); + } + + #[test] + fn name_mismatch_rename_type() { + assert_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::chain] + fn handle_greet(args: EntryHello) {} + }); + } + + #[test] + fn handle_no_params() { + assert_detected!(super::linter, syn::ItemFn => { + #[::mingling::macros::chain] + fn handle_greet() {} + }); + } + #[test] + fn regular_fn_ok() { + assert_not_detected!(super::linter, syn::ItemFn => { + fn do_something(x: i32) {} + }); + } + + #[test] + fn handle_no_params_and_no_attrs() { + assert_not_detected!(super::linter, syn::ItemFn => { fn handle_greet() {} }); + } +} diff --git a/mingling_cli/tmpls/lints.tmpl b/mingling_cli/tmpls/lints.tmpl index 3b20ed4..590828a 100644 --- a/mingling_cli/tmpls/lints.tmpl +++ b/mingling_cli/tmpls/lints.tmpl @@ -2,30 +2,26 @@ use crate::linter::mlint_report::{MlintLevel, MlintReport}; >>>>>>>>>> impls; +>>>>>>>>>> file_lints; /// Run all registered lints on a parsed file with its source text. pub fn run_all_lints(file: &syn::File, source: &str) -> Vec<MlintReport> { use crate::linter::mlint_attr::{get_mlint_override, MlintLevelOverride}; - let mut reports = vec![]; + + // File-level lints (check types, modules, etc.) + let mut reports: Vec<MlintReport> = vec![]; +>>>>>>>>>> file_calls; + + // Item-level lints (check each function) for item in &file.items { if let syn::Item::Fn(f) = item { >>>>>>>>>> calls; - -@@@ >>> calls - let skip = get_mlint_override(&f.attrs, "<<<name>>>") == Some(MlintLevelOverride::Allow); - if !skip { - let mut rs = <<<name>>>::linter(f.clone(), source); - if get_mlint_override(&f.attrs, "<<<name>>>") == Some(MlintLevelOverride::Deny) { - for r in &mut rs { r.level = MlintLevel::Error; } - } - reports.extend(rs); - } -@@@ <<< } } + + // Apply file-level #![mlint(allow/warn/deny(...))] overrides for r in &mut reports { - let name = &r.lint_code; - if let Some(override_level) = get_mlint_override(&file.attrs, name) { + if let Some(override_level) = get_mlint_override(&file.attrs, &r.lint_code) { match override_level { MlintLevelOverride::Allow => r.level = MlintLevel::Help, MlintLevelOverride::Deny => r.level = MlintLevel::Error, @@ -42,8 +38,6 @@ pub fn run_all_lints(file: &syn::File, source: &str) -> Vec<MlintReport> { #[macro_export] macro_rules! assert_detected { ($linter:expr, $ast_type:ty => { $($code:tt)* }) => { - // $($code:tt)* captures tokens INSIDE the braces, not including the braces - // e.g. `fn foo() { ... }` — exactly what syn::ItemFn expects let source = stringify!($($code)*); let ast: $ast_type = syn::parse_str(&source).unwrap(); assert!(!$linter(ast, &source).is_empty()); @@ -59,7 +53,26 @@ macro_rules! assert_not_detected { }; } +@@@ >>> file_calls + reports.extend(<<<name>>>::check_file(file, source)); +@@@ <<< + +@@@ >>> file_lints +pub use <<<name>>>::check_file as <<<name>>>_file; +@@@ <<< + @@@ >>> impls mod <<<mod_name>>>; pub use <<<mod_name>>>::linter as <<<mod_name>>>; @@@ <<< + +@@@ >>> calls + let skip = get_mlint_override(&f.attrs, "<<<name>>>") == Some(MlintLevelOverride::Allow); + if !skip { + let mut rs = <<<name>>>::linter(f.clone(), source); + if get_mlint_override(&f.attrs, "<<<name>>>") == Some(MlintLevelOverride::Deny) { + for r in &mut rs { r.level = MlintLevel::Error; } + } + reports.extend(rs); + } +@@@ <<< |
