aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mingling_cli/build.rs2
-rw-r--r--mingling_cli/pre/lint_registry.rs16
-rw-r--r--mingling_cli/src/linter/mlint_report.rs40
-rw-r--r--mingling_cli/src/lints.rs26
-rw-r--r--mingling_cli/src/lints/non_mingling_naming_style.rs351
-rw-r--r--mingling_cli/tmpls/lints.tmpl45
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);
+ }
+@@@ <<<