aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_cli/src')
-rw-r--r--mingling_cli/src/diagnostic.rs99
-rw-r--r--mingling_cli/src/errors.rs1
-rw-r--r--mingling_cli/src/errors/serde_json.rs8
-rw-r--r--mingling_cli/src/linter.rs67
-rw-r--r--mingling_cli/src/linter/cmd_mlint.rs119
-rw-r--r--mingling_cli/src/linter/mlint_attr.rs39
-rw-r--r--mingling_cli/src/linter/mlint_report.rs445
-rw-r--r--mingling_cli/src/lints.rs82
-rw-r--r--mingling_cli/src/lints/non_mingling_naming_style.rs352
-rw-r--r--mingling_cli/src/lints/template_linter.rs56
-rw-r--r--mingling_cli/src/lints/unnecessary_render_result_creation.rs271
-rw-r--r--mingling_cli/src/main.rs29
-rw-r--r--mingling_cli/src/message.rs10
-rw-r--r--mingling_cli/src/metadata.rs24
-rw-r--r--mingling_cli/src/metadata/cmd_metadata.rs17
-rw-r--r--mingling_cli/src/metadata/setup.rs136
16 files changed, 1755 insertions, 0 deletions
diff --git a/mingling_cli/src/diagnostic.rs b/mingling_cli/src/diagnostic.rs
new file mode 100644
index 0000000..527e879
--- /dev/null
+++ b/mingling_cli/src/diagnostic.rs
@@ -0,0 +1,99 @@
+use annotate_snippets::level::{ERROR, HELP, NOTE, WARNING};
+use annotate_snippets::{AnnotationKind, Group, Renderer, Snippet};
+use cargo_metadata::diagnostic::Diagnostic;
+use mingling::macros::{buffer, group, r_println, renderer};
+
+group!(Diagnostic);
+
+#[renderer(buffer)]
+pub fn render_diagnostic(diagnostic: Diagnostic) {
+ let report = diagnostic_to_report(&diagnostic);
+ let renderer = Renderer::styled();
+ let rendered = renderer.render(&report);
+ r_println!("{rendered}");
+}
+
+fn cargo_level_to_annotate(
+ level: cargo_metadata::diagnostic::DiagnosticLevel,
+) -> &'static annotate_snippets::Level<'static> {
+ use cargo_metadata::diagnostic::DiagnosticLevel;
+ match level {
+ DiagnosticLevel::Ice | DiagnosticLevel::Error => &ERROR,
+ DiagnosticLevel::Warning => &WARNING,
+ DiagnosticLevel::Note | DiagnosticLevel::FailureNote => &NOTE,
+ DiagnosticLevel::Help => &HELP,
+ _ => &ERROR,
+ }
+}
+
+/// 把 1-based char offset 转成 0-based byte offset
+fn char_offset_to_byte_offset(s: &str, char_offset: usize) -> usize {
+ s.char_indices()
+ .nth(char_offset.saturating_sub(1))
+ .map(|(i, _)| i)
+ .unwrap_or(s.len())
+}
+
+fn diagnostic_to_report<'a>(d: &'a Diagnostic) -> Vec<Group<'a>> {
+ let level = cargo_level_to_annotate(d.level);
+ let mut title = level.clone().primary_title(d.message.as_str());
+ if let Some(ref code) = d.code {
+ title = title.id(code.code.as_str());
+ }
+
+ let mut group = Group::with_title(title);
+
+ for span in &d.spans {
+ if !span.is_primary {
+ continue;
+ }
+
+ let source: String = span
+ .text
+ .iter()
+ .map(|l| l.text.as_str())
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ let byte_range = if span.text.len() == 1 {
+ let line = &span.text[0];
+ let start = char_offset_to_byte_offset(&line.text, line.highlight_start);
+ let end = char_offset_to_byte_offset(&line.text, line.highlight_end);
+ start..end
+ } else {
+ let first = &span.text[0];
+ let last = span.text.last().unwrap();
+ let start = char_offset_to_byte_offset(&first.text, first.highlight_start);
+ let prefix_len: usize = span.text[..span.text.len() - 1]
+ .iter()
+ .map(|l| l.text.len() + 1)
+ .sum();
+ let end = prefix_len + char_offset_to_byte_offset(&last.text, last.highlight_end);
+ start..end
+ };
+
+ let mut snippet = Snippet::source(source)
+ .line_start(span.line_start)
+ .path(span.file_name.as_str());
+
+ if let Some(ref label) = span.label {
+ let annotation = AnnotationKind::Primary
+ .span(byte_range)
+ .label(label.as_str());
+ snippet = snippet.annotation(annotation);
+ } else {
+ let annotation = AnnotationKind::Primary.span(byte_range);
+ snippet = snippet.annotation(annotation);
+ }
+
+ group = group.element(snippet);
+ }
+
+ for child in &d.children {
+ let msg_level = cargo_level_to_annotate(child.level);
+ let msg = msg_level.clone().message(child.message.as_str());
+ group = group.element(msg);
+ }
+
+ vec![group]
+}
diff --git a/mingling_cli/src/errors.rs b/mingling_cli/src/errors.rs
new file mode 100644
index 0000000..cef9616
--- /dev/null
+++ b/mingling_cli/src/errors.rs
@@ -0,0 +1 @@
+pub mod serde_json;
diff --git a/mingling_cli/src/errors/serde_json.rs b/mingling_cli/src/errors/serde_json.rs
new file mode 100644
index 0000000..c73329c
--- /dev/null
+++ b/mingling_cli/src/errors/serde_json.rs
@@ -0,0 +1,8 @@
+use mingling::macros::{buffer, group, r_println, renderer};
+
+group!(ErrorSerdeJson = serde_json::Error);
+
+#[renderer(buffer)]
+pub fn render_error_serde_json(_err: ErrorSerdeJson) {
+ r_println!("serde");
+}
diff --git a/mingling_cli/src/linter.rs b/mingling_cli/src/linter.rs
new file mode 100644
index 0000000..c85afc1
--- /dev/null
+++ b/mingling_cli/src/linter.rs
@@ -0,0 +1,67 @@
+use mingling::{
+ Program,
+ macros::{chain, dispatcher, entry, program_setup},
+};
+
+use crate::{
+ linter::cmd_mlint::{CMDMinglingLinter, EntryMinglingLinter},
+ metadata::setup::ResUsingJson,
+};
+
+pub mod cmd_mlint;
+pub mod mlint_attr;
+pub mod mlint_report;
+
+#[program_setup]
+pub fn mingling_linter_setup(program: &mut Program<crate::ThisProgram>) {
+ program.with_setup(MinglingLinterCommandSetup);
+}
+
+#[program_setup]
+pub fn mingling_linter_command_setup(program: &mut Program<crate::ThisProgram>) {
+ program.with_dispatcher(CMDMinglingLinter);
+ program.with_dispatcher(CMDLinterSupportRustAnalyzer);
+ program.with_dispatcher(CMDLinterSupportRustAnalyzerWithClippy);
+ program.with_dispatcher(CMDLinterSupportRustAnalyzerWithCheck);
+}
+
+// Aliases
+
+dispatcher!("ra-lint-clippy",
+ CMDLinterSupportRustAnalyzerWithClippy => EntryLinterSupportRustAnalyzerWithClippy
+);
+
+dispatcher!("ra-lint-check",
+ CMDLinterSupportRustAnalyzerWithCheck => EntryLinterSupportRustAnalyzerWithCheck
+);
+
+dispatcher!("ra-lint",
+ CMDLinterSupportRustAnalyzer => EntryLinterSupportRustAnalyzer
+);
+
+#[chain]
+pub fn handle_ra_lint(
+ _: EntryLinterSupportRustAnalyzer,
+ use_json: &mut ResUsingJson,
+) -> EntryMinglingLinter {
+ use_json.using = true;
+ entry!("--message-format=json")
+}
+
+#[chain]
+pub fn handle_ra_lint_check(
+ _: EntryLinterSupportRustAnalyzerWithCheck,
+ use_json: &mut ResUsingJson,
+) -> EntryMinglingLinter {
+ use_json.using = true;
+ entry!("--message-format=json", "--with-checker=cargo,check")
+}
+
+#[chain]
+pub fn handle_ra_lint_clippy(
+ _: EntryLinterSupportRustAnalyzerWithClippy,
+ use_json: &mut ResUsingJson,
+) -> EntryMinglingLinter {
+ 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
new file mode 100644
index 0000000..90871af
--- /dev/null
+++ b/mingling_cli/src/linter/cmd_mlint.rs
@@ -0,0 +1,119 @@
+use crate::linter::mlint_report::{MlintReport, StateLintReports};
+use cargo_metadata::Metadata;
+use mingling::LazyRes;
+use mingling::consts::REMAINS;
+use mingling::macros::{arg, chain, dispatcher, pack};
+use mingling::picker::EntryPicker;
+
+dispatcher!("lint", CMDMinglingLinter => EntryMinglingLinter);
+
+/// Main linting function that processes all packages in the metadata.
+///
+/// Iterates through all packages and their targets (e.g., binaries, libraries, tests),
+/// reads Rust source files (`.rs`), parses them into ASTs, runs lint checks,
+/// and enriches each report with metadata information.
+async fn linter_main(metadata: &Metadata) -> Vec<MlintReport> {
+ // Vector to accumulate all lint reports
+ let mut all_reports = Vec::new();
+
+ // Iterate over all packages in the metadata
+ for package in &metadata.packages {
+ // Iterate over all targets within a package (e.g., bin, lib, test, etc.)
+ for target in &package.targets {
+ let path = &target.src_path;
+ // Only process Rust source files (with `.rs` extension)
+ if !path.as_str().ends_with(".rs") {
+ continue;
+ }
+ // Read the source file content
+ let source = match std::fs::read_to_string(path.as_str()) {
+ Ok(s) => s,
+ Err(_) => continue,
+ };
+ // Parse the source file into an AST (Abstract Syntax Tree)
+ let ast = match syn::parse_file(&source) {
+ Ok(f) => f,
+ Err(_) => continue,
+ };
+
+ // Run all lint checks and collect reports
+ let reports = crate::lints::run_all_lints(&ast, &source);
+ //
+ for mut r in reports {
+ // Enrich report with metadata information
+ r.file_name = path.as_str().to_string();
+ r.source_code = source.clone();
+ r.package_id = Some(package.id.to_string());
+ r.target_name = Some(target.name.clone());
+ r.target_kind = target.kind.first().map(|k| k.to_string());
+ r.target_src_path = Some(path.as_str().to_string());
+ all_reports.push(r);
+ }
+ }
+ }
+
+ all_reports
+}
+
+pack!(StateBeginLinter = ());
+
+#[chain]
+pub fn handle_lint(args: EntryMinglingLinter) -> StateBeginLinter {
+ let (with_checker, checker_args) = args
+ .pick_or(&arg![with_checker: Option<String>], || {
+ Some("cargo,check".to_string())
+ })
+ .pick(&REMAINS)
+ .unwrap();
+
+ // If with_checker is not set, proceed directly to the mingling lint phase
+ let Some(with_checker) = with_checker else {
+ return StateBeginLinter::new(());
+ };
+
+ let with_checker: Vec<&str> = with_checker.split(',').collect();
+ let checker_args: Vec<String> = checker_args.into();
+
+ // Run the outer checker (e.g. cargo check) with output passed through directly
+ execute_checker(&with_checker, checker_args.as_slice());
+
+ StateBeginLinter::new(())
+}
+
+/// Run the outer checker (e.g. cargo check) with output passed through directly.
+fn execute_checker(with_checker: &[&str], checker_args: &[String]) {
+ if with_checker.is_empty() {
+ return;
+ }
+
+ let checker_str = with_checker.join(" ");
+ let args_str = checker_args.join(" ");
+ let full_cmd = if args_str.is_empty() {
+ checker_str
+ } else {
+ format!("{} {}", checker_str, args_str)
+ };
+
+ let mut cmd = if cfg!(target_os = "windows") {
+ let mut c = std::process::Command::new("cmd");
+ c.args(["/C", &full_cmd]);
+ c
+ } else {
+ let mut c = std::process::Command::new("sh");
+ c.args(["-c", &full_cmd]);
+ c
+ };
+
+ // Pass through stdin/stdout/stderr so the user sees everything
+ let _ = cmd.status();
+}
+
+#[chain]
+pub async fn handle_state_begin_linter(
+ _: StateBeginLinter,
+ metadata: &mut LazyRes<crate::metadata::setup::ResMetadata>,
+) -> StateLintReports {
+ let metadata = metadata.get_ref().data();
+ let reports = linter_main(metadata).await;
+ StateLintReports::new(reports)
+}
diff --git a/mingling_cli/src/linter/mlint_attr.rs b/mingling_cli/src/linter/mlint_attr.rs
new file mode 100644
index 0000000..24528d4
--- /dev/null
+++ b/mingling_cli/src/linter/mlint_attr.rs
@@ -0,0 +1,39 @@
+/// Result of checking `mlint(allow/warn/deny(...))` attributes.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum MlintLevelOverride {
+ Allow,
+ Warn,
+ Deny,
+}
+
+/// Parse `mlint(allow/warn/deny(lint_name))` from attributes.
+/// Returns `None` if the lint is not mentioned in any mlint attribute.
+pub fn get_mlint_override(attrs: &[syn::Attribute], lint_name: &str) -> Option<MlintLevelOverride> {
+ for attr in attrs {
+ if !attr.path().is_ident("mlint") {
+ continue;
+ }
+ let list = match attr.meta.require_list() {
+ Ok(l) => l,
+ Err(_) => continue,
+ };
+ // TokenStream::to_string() adds spaces between tokens,
+ // e.g. `allow(xxx)` → `allow ( xxx )`. Remove all spaces to compare.
+ let flat: String = list
+ .tokens
+ .to_string()
+ .chars()
+ .filter(|c| !c.is_whitespace())
+ .collect();
+ for (keyword, variant) in [
+ ("allow", MlintLevelOverride::Allow),
+ ("warn", MlintLevelOverride::Warn),
+ ("deny", MlintLevelOverride::Deny),
+ ] {
+ if flat.contains(&format!("{keyword}({lint_name})")) {
+ return Some(variant);
+ }
+ }
+ }
+ None
+}
diff --git a/mingling_cli/src/linter/mlint_report.rs b/mingling_cli/src/linter/mlint_report.rs
new file mode 100644
index 0000000..8195f77
--- /dev/null
+++ b/mingling_cli/src/linter/mlint_report.rs
@@ -0,0 +1,445 @@
+use std::ops::Range;
+
+use cargo_metadata::diagnostic::{
+ DiagnosticCodeBuilder, DiagnosticLevel as CargoLevel, DiagnosticSpanBuilder,
+ DiagnosticSpanLineBuilder,
+};
+
+use cargo_metadata::{Message, PackageId};
+
+use annotate_snippets::level::{ERROR, HELP, NOTE, WARNING};
+use annotate_snippets::{AnnotationKind, Group, Patch, Renderer, Snippet};
+use mingling::macros::{buffer, chain, pack, r_append, r_eprintln, renderer};
+use mingling::{AnyOutput, ProgramCollect, Routable};
+
+use crate::Next;
+use crate::metadata::setup::ResUsingJson;
+
+/// Complete structure of a Lint report, containing inspection results and associated metadata.
+#[derive(Default)]
+pub struct MlintReport {
+ /// Source file name
+ pub file_name: String,
+
+ /// Full source text of the file, used to extract line content and compute byte offsets
+ pub source_code: String,
+
+ /// Severity level of the report
+ pub level: MlintLevel,
+
+ /// Name of the Lint
+ pub lint_code: String,
+
+ /// Content of the report
+ pub message: String,
+
+ /// Source code locations
+ pub spans: Vec<LintSpan>,
+
+ /// Attached sub-reports for this report
+ pub attached_reports: Vec<MlintReport>,
+
+ /// Package ID that this report belongs to
+ pub package_id: Option<String>,
+
+ /// Compilation target name that this report belongs to
+ pub target_name: Option<String>,
+
+ /// Compilation target type that this report belongs to
+ pub target_kind: Option<String>,
+
+ /// 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.
+#[derive(Default, Clone, Copy, PartialEq, Eq)]
+pub enum MlintLevel {
+ #[default]
+ Note,
+ Error,
+ Warning,
+ Help,
+}
+
+/// Source code location span, representing a range of source code and its associated text information.
+pub struct LintSpan {
+ /// Starting line number (1-based)
+ pub line_start: usize,
+ /// Ending line number (1-based)
+ pub line_end: usize,
+ /// Starting column (1-based char offset)
+ pub column_start: usize,
+ /// Ending column (1-based char offset)
+ pub column_end: usize,
+ /// Source lines at this location
+ pub text: Vec<LintSpanLine>,
+ /// Optional label description
+ pub label: Option<String>,
+}
+
+/// A single line of text in a source location with highlight range.
+pub struct LintSpanLine {
+ /// Full source line text (no trailing `\n`)
+ pub text: String,
+ /// Highlight start (1-based char offset)
+ pub highlight_start: usize,
+ /// Highlight end (1-based char offset)
+ 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 {
+ let span = value.span();
+ let start = span.start();
+ let end = span.end();
+
+ // Extract line content from source
+ let lines: Vec<&str> = source.lines().collect();
+ let text = if start.line == end.line && start.line <= lines.len() {
+ let line_text = lines[start.line.saturating_sub(1)];
+ let hl_start = proc_macro2_byte_col_to_char_1based(line_text, start.column);
+ let hl_end = proc_macro2_byte_col_to_char_1based(line_text, end.column);
+ vec![LintSpanLine {
+ text: line_text.to_string(),
+ highlight_start: hl_start,
+ highlight_end: hl_end,
+ }]
+ } else {
+ // Multi-line: generate line by line
+ (start.line..=end.line.min(lines.len()))
+ .map(|i| {
+ let line_text = lines[i.saturating_sub(1)];
+ let (hl_start, hl_end) = if i == start.line {
+ (
+ proc_macro2_byte_col_to_char_1based(line_text, start.column),
+ line_text.chars().count(),
+ )
+ } else if i == end.line {
+ (
+ 1,
+ proc_macro2_byte_col_to_char_1based(line_text, end.column),
+ )
+ } else {
+ (1, line_text.chars().count())
+ };
+ LintSpanLine {
+ text: line_text.to_string(),
+ highlight_start: hl_start,
+ highlight_end: hl_end,
+ }
+ })
+ .collect::<Vec<_>>()
+ };
+
+ LintSpan {
+ line_start: start.line,
+ line_end: end.line,
+ column_start: proc_macro2_byte_col_to_char_1based(
+ lines.get(start.line.saturating_sub(1)).unwrap_or(&""),
+ start.column,
+ ),
+ column_end: proc_macro2_byte_col_to_char_1based(
+ lines.get(end.line.saturating_sub(1)).unwrap_or(&""),
+ end.column,
+ ),
+ text,
+ label: None,
+ }
+ }
+
+ /// Compute byte offset from (line, column) within source.
+ /// line: 1-based, column: 1-based char offset.
+ pub fn line_col_to_byte_offset(&self, line: usize, col: usize) -> usize {
+ let mut byte_pos = 0usize;
+ for (i, line_str) in self.source_code.lines().enumerate() {
+ if i + 1 == line {
+ return byte_pos + char_1based_to_byte_offset(line_str, col);
+ }
+ byte_pos += line_str.len() + 1; // +1 for \n
+ }
+ self.source_code.len()
+ }
+}
+
+/// proc-macro2's LineColumn.column is **0-based byte offset**.
+/// Convert to 1-based char offset.
+fn proc_macro2_byte_col_to_char_1based(line: &str, byte_col: usize) -> usize {
+ line.char_indices()
+ .position(|(i, _)| i >= byte_col)
+ .map(|pos| pos + 1) // → 1-based
+ .unwrap_or(line.chars().count().max(1))
+}
+
+/// 1-based char offset → byte offset within a string
+fn char_1based_to_byte_offset(s: &str, char_1based: usize) -> usize {
+ s.char_indices()
+ .nth(char_1based.saturating_sub(1))
+ .map(|(i, _)| i)
+ .unwrap_or(s.len())
+}
+
+impl MlintReport {
+ pub fn to_annotate_snippet_render(&self) -> String {
+ let level = mlinit_level_to_annotate(&self.level);
+ let title = level.clone().primary_title(&self.message);
+ // code 不放在 title 里,改放在 note 中
+ let mut group = Group::with_title(title);
+
+ for span in &self.spans {
+ let source = span
+ .text
+ .iter()
+ .map(|l| l.text.as_str())
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ let byte_range = if span.text.len() == 1 {
+ let line = &span.text[0];
+ let start = char_1based_to_byte_offset(&line.text, line.highlight_start);
+ let end = char_1based_to_byte_offset(&line.text, line.highlight_end);
+ start..end
+ } else {
+ let first = &span.text[0];
+ let last = span.text.last().unwrap();
+ let start = char_1based_to_byte_offset(&first.text, first.highlight_start);
+ let prefix_len: usize = span.text[..span.text.len() - 1]
+ .iter()
+ .map(|l| l.text.len() + 1)
+ .sum();
+ let end = prefix_len + char_1based_to_byte_offset(&last.text, last.highlight_end);
+ start..end
+ };
+
+ let mut snippet = Snippet::source(source)
+ .line_start(span.line_start)
+ .path(&self.file_name);
+
+ let annotation = match &span.label {
+ Some(label) => AnnotationKind::Primary
+ .span(byte_range)
+ .label(label.as_str()),
+ None => AnnotationKind::Primary.span(byte_range),
+ };
+ snippet = snippet.annotation(annotation);
+ group = group.element(snippet);
+ }
+
+ for child in &self.attached_reports {
+ let child_level = mlinit_level_to_annotate(&child.level);
+ let msg = child_level.clone().message(&child.message);
+ 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",
+ MlintLevel::Warning => "warn",
+ MlintLevel::Note | MlintLevel::Help => "allow",
+ };
+ let note_text = format!("`#[mlint({level_name}({}))]` on by default", self.lint_code);
+ let note_msg = NOTE.clone().message(note_text);
+ group = group.element(note_msg);
+ }
+
+ let renderer = Renderer::styled();
+ renderer.render(&[group])
+ }
+}
+
+fn mlinit_level_to_annotate(level: &MlintLevel) -> &'static annotate_snippets::Level<'static> {
+ match level {
+ MlintLevel::Error => &ERROR,
+ MlintLevel::Warning => &WARNING,
+ MlintLevel::Note => &NOTE,
+ MlintLevel::Help => &HELP,
+ }
+}
+
+impl MlintReport {
+ pub fn to_compiler_message(&self) -> Message {
+ use cargo_metadata::{CompilerMessageBuilder, Edition, TargetBuilder};
+
+ let target_kind_str = self.target_kind.as_deref().unwrap_or("bin");
+ let target_kind_parsed: cargo_metadata::TargetKind = target_kind_str.into();
+ let crate_kind: cargo_metadata::CrateType = target_kind_str.into();
+
+ let target = TargetBuilder::default()
+ .name(self.target_name.as_deref().unwrap_or_default())
+ .kind(vec![target_kind_parsed])
+ .crate_types(vec![crate_kind])
+ .required_features(Vec::<String>::new())
+ .src_path(self.target_src_path.as_deref().unwrap_or_default())
+ .edition(Edition::E2021)
+ .doctest(false)
+ .test(false)
+ .doc(false)
+ .build()
+ .unwrap();
+
+ let diagnostic = self.build_diagnostic(
+ &self.message,
+ &self.lint_code,
+ &self.level,
+ &self.spans,
+ &self.attached_reports,
+ );
+
+ Message::CompilerMessage(
+ CompilerMessageBuilder::default()
+ .package_id(PackageId {
+ repr: self.package_id.clone().unwrap_or_else(|| "unknown".into()),
+ })
+ .target(target)
+ .message(diagnostic)
+ .build()
+ .unwrap(),
+ )
+ }
+
+ fn build_diagnostic(
+ &self,
+ message: &str,
+ code: &str,
+ level: &MlintLevel,
+ spans: &[LintSpan],
+ children: &[MlintReport],
+ ) -> cargo_metadata::diagnostic::Diagnostic {
+ cargo_metadata::diagnostic::DiagnosticBuilder::default()
+ .message(message)
+ .code(
+ DiagnosticCodeBuilder::default()
+ .code(code)
+ .explanation(None)
+ .build()
+ .unwrap(),
+ )
+ .level(match level {
+ MlintLevel::Error => CargoLevel::Error,
+ MlintLevel::Warning => CargoLevel::Warning,
+ MlintLevel::Note => CargoLevel::Note,
+ MlintLevel::Help => CargoLevel::Help,
+ })
+ .spans(
+ spans
+ .iter()
+ .map(|s| self.lint_span_to_diagnostic_span(s))
+ .collect::<Vec<_>>(),
+ )
+ .children(
+ children
+ .iter()
+ .map(|c| {
+ self.build_diagnostic(
+ &c.message,
+ &c.lint_code,
+ &c.level,
+ &c.spans,
+ &c.attached_reports,
+ )
+ })
+ .collect::<Vec<_>>(),
+ )
+ .rendered(None)
+ .build()
+ .unwrap()
+ }
+
+ fn lint_span_to_diagnostic_span(
+ &self,
+ span: &LintSpan,
+ ) -> cargo_metadata::diagnostic::DiagnosticSpan {
+ let byte_start = self.line_col_to_byte_offset(span.line_start, span.column_start);
+ let byte_end = self.line_col_to_byte_offset(span.line_end, span.column_end);
+
+ DiagnosticSpanBuilder::default()
+ .file_name(&self.file_name)
+ .byte_start(byte_start as u32)
+ .byte_end(byte_end as u32)
+ .line_start(span.line_start)
+ .line_end(span.line_end)
+ .column_start(span.column_start)
+ .column_end(span.column_end)
+ .is_primary(true)
+ .text(
+ span.text
+ .iter()
+ .map(|l| {
+ DiagnosticSpanLineBuilder::default()
+ .text(&l.text)
+ .highlight_start(l.highlight_start)
+ .highlight_end(l.highlight_end)
+ .build()
+ .unwrap()
+ })
+ .collect::<Vec<_>>(),
+ )
+ .label(span.label.clone())
+ .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()
+ }
+}
+
+pack!(StateLintReports = Vec<MlintReport>);
+pack!(ResultLintReportsAnnotateSnippet = Vec<MlintReport>);
+pack!(ResultLintReportsJson = Vec<MlintReport>);
+
+#[chain]
+pub fn handle_state_lint_reports(reports: StateLintReports, using_json: &ResUsingJson) -> Next {
+ if using_json.using {
+ ResultLintReportsJson::new(reports.inner).to_render()
+ } else {
+ ResultLintReportsAnnotateSnippet::new(reports.inner).to_render()
+ }
+}
+
+#[renderer(buffer)]
+pub fn render_lint_reports(reports: ResultLintReportsAnnotateSnippet) {
+ for report in reports.inner {
+ r_eprintln!("{}", report.to_annotate_snippet_render());
+ }
+}
+
+#[renderer(buffer)]
+pub fn render_lint_reports_json(reports: ResultLintReportsJson) {
+ for report in reports.inner {
+ // DIRTY: Dispatch to the Message renderer using AnyOutput to obtain the render result and append it to the Buffer
+ r_append!(|| { crate::ThisProgram::render(AnyOutput::new(report.to_compiler_message())) });
+ }
+}
diff --git a/mingling_cli/src/lints.rs b/mingling_cli/src/lints.rs
new file mode 100644
index 0000000..89f5ca3
--- /dev/null
+++ b/mingling_cli/src/lints.rs
@@ -0,0 +1,82 @@
+#![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};
+
+ // 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);
+ if get_mlint_override(&f.attrs, "template_linter") == Some(MlintLevelOverride::Deny) {
+ for r in &mut rs { r.level = MlintLevel::Error; }
+ }
+ reports.extend(rs);
+ }
+ let skip = get_mlint_override(&f.attrs, "unnecessary_render_result_creation") == Some(MlintLevelOverride::Allow);
+ if !skip {
+ let mut rs = unnecessary_render_result_creation::linter(f.clone(), source);
+ if get_mlint_override(&f.attrs, "unnecessary_render_result_creation") == 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 {
+ 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,
+ MlintLevelOverride::Warn => {
+ if r.level != MlintLevel::Error { r.level = MlintLevel::Warning; }
+ }
+ }
+ }
+ }
+ reports.retain(|r| r.level != MlintLevel::Help);
+ reports
+}
+
+#[macro_export]
+macro_rules! assert_detected {
+ ($linter:expr, $ast_type:ty => { $($code:tt)* }) => {
+ let source = stringify!($($code)*);
+ let ast: $ast_type = syn::parse_str(&source).unwrap();
+ assert!(!$linter(ast, &source).is_empty());
+ };
+}
+
+#[macro_export]
+macro_rules! assert_not_detected {
+ ($linter:expr, $ast_type:ty => { $($code:tt)* }) => {
+ let source = stringify!($($code)*);
+ let ast: $ast_type = syn::parse_str(&source).unwrap();
+ assert!($linter(ast, &source).is_empty());
+ };
+} \ No newline at end of file
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..6481926
--- /dev/null
+++ b/mingling_cli/src/lints/non_mingling_naming_style.rs
@@ -0,0 +1,352 @@
+//! 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/src/lints/template_linter.rs b/mingling_cli/src/lints/template_linter.rs
new file mode 100644
index 0000000..271aa0c
--- /dev/null
+++ b/mingling_cli/src/lints/template_linter.rs
@@ -0,0 +1,56 @@
+//! Template Linter
+//!
+//! ## Summary
+//!
+//! This is a template Linter that introduces how to add a Lint for Mingling.
+//! You can write an introduction for this Linter in the Summary section, for example:
+//!
+//! - Trigger conditions
+//! - Why is it necessary?
+//!
+//! ## Metadata
+//!
+//! > This section is the **Metadata** section, which needs to be filled in correctly.
+//! > These contents will eventually be compiled as the Linter's behavior.
+//!
+//! Author: `Weicao-CatilGrass`
+//! Default: `allow`
+// ^^^^^ Supported parameters: `warn`, `allow`, `deny`
+
+// --- ABOUT AUTO IDENTIFICATION RULES ---
+//
+// The compiler will treat code with the following structure as a Linter entry point:
+// |
+// --> your_linter_module.rs
+// |
+// | pub fn linter(ast: syn::ItemX) -> Vec<MlintReport> {
+// | /* ... */ ^^^^^^^^^^ Your linter scope
+// | }
+// |
+// = note: Please ensure your function is `pub`, named `linter`, and returns `Vec<MlintReport>`
+//
+// --- ABOUT AUTO IDENTIFICATION RULES ---
+
+use crate::linter::mlint_report::MlintReport;
+
+pub fn linter(_ast: syn::ItemFn, _source: &str) -> Vec<MlintReport> {
+ // ^^^^^^^^^^^ Supported parameters:
+ // | syn::File
+ // | syn::ItemImpl
+ // | syn::ItemStruct
+ // | syn::ItemEnum
+ // | syn::ItemTrait
+ // | syn::ItemFn
+ // | syn::ItemMacro
+ // | syn::ItemMod
+ // | syn::ItemUnion
+ vec![]
+}
+
+#[cfg(test)]
+mod lint_test {
+ use crate::{assert_detected, assert_not_detected};
+
+ #[test]
+ fn test() {}
+}
diff --git a/mingling_cli/src/lints/unnecessary_render_result_creation.rs b/mingling_cli/src/lints/unnecessary_render_result_creation.rs
new file mode 100644
index 0000000..03f81d2
--- /dev/null
+++ b/mingling_cli/src/lints/unnecessary_render_result_creation.rs
@@ -0,0 +1,271 @@
+//! Unnecessary Manual RenderResult Creation
+//!
+//! ## Summary
+//!
+//! Detects `#[renderer]` functions that manually create a `RenderResult` and
+//! manage it via `r_println!(r, ...)` style calls, when they could be simplified
+//! to `#[renderer(buffer)]` which handles the buffer automatically.
+//!
+//! This lint will not trigger if `r` is used outside of `r_println!`,
+//! `r_eprintln!`, `r_print`, `r_eprint`, or `r_append`.
+//!
+//! ## Metadata
+//!
+//! Author: `Weicao-CatilGrass`
+//! Default: `warn`
+
+use crate::linter::mlint_report::{MlintLevel, MlintReport};
+use quote::ToTokens;
+
+pub fn linter(ast: syn::ItemFn, source: &str) -> Vec<MlintReport> {
+ if !has_renderer_attr(&ast) {
+ return vec![];
+ }
+
+ let stmts = &ast.block.stmts;
+ if stmts.len() < 2 {
+ return vec![];
+ }
+
+ let (r_ident, let_idx) = match find_render_result_new(stmts) {
+ Some(pair) => pair,
+ None => return vec![],
+ };
+
+ let r_name = r_ident.to_string();
+ let mut only_print_and_return = true;
+ let mut print_count = 0;
+
+ for stmt in &stmts[let_idx + 1..] {
+ if !check_stmt_usage(stmt, &r_name, &mut print_count) {
+ only_print_and_return = false;
+ break;
+ }
+ }
+
+ if only_print_and_return && print_count > 0 {
+ let span = MlintReport::span_from_syn(&ast.sig, source);
+ vec![MlintReport {
+ source_code: source.to_string(),
+ level: MlintLevel::Warning,
+ lint_code: "unnecessary_render_result_creation".into(),
+ message: format!(
+ "unnecessary manual `RenderResult` creation in `{}`: use `#[renderer(buffer)]` instead",
+ ast.sig.ident,
+ ),
+ spans: vec![span],
+ attached_reports: vec![MlintReport {
+ level: MlintLevel::Help,
+ message: format!(
+ "change to `#[renderer(buffer)]` and use `r_println!(...)` without the `{}` parameter",
+ r_name,
+ ),
+ ..Default::default()
+ }],
+ ..Default::default()
+ }]
+ } else {
+ vec![]
+ }
+}
+
+fn has_renderer_attr(func: &syn::ItemFn) -> bool {
+ func.attrs.iter().any(|a| {
+ if !a.path().is_ident("renderer") {
+ return false;
+ }
+ if let Ok(list) = a.meta.require_list() {
+ let tokens = list.tokens.to_string();
+ if tokens.contains("buffer") {
+ return false;
+ }
+ }
+ true
+ })
+}
+
+fn find_render_result_new(stmts: &[syn::Stmt]) -> Option<(proc_macro2::Ident, usize)> {
+ for (i, stmt) in stmts.iter().enumerate() {
+ if let syn::Stmt::Local(local) = stmt
+ && let Some(init) = &local.init
+ && let syn::Pat::Ident(pat_id) = &local.pat
+ && pat_id.mutability.is_some()
+ && let syn::Expr::Call(call) = &*init.expr
+ && let syn::Expr::Path(expr_path) = call.func.as_ref()
+ {
+ let segs = &expr_path.path.segments;
+ let matches = match segs.len() {
+ 2 => {
+ segs[0].ident == "RenderResult"
+ && (segs[1].ident == "new" || segs[1].ident == "default")
+ }
+ 3 => {
+ segs[0].ident == "mingling"
+ && segs[1].ident == "RenderResult"
+ && (segs[2].ident == "new" || segs[2].ident == "default")
+ }
+ _ => false,
+ };
+ if matches {
+ return Some((pat_id.ident.clone(), i));
+ }
+ }
+
+ // Also handle `RenderResult::from(...)` and `mingling::RenderResult::from(...)`
+ if let syn::Stmt::Local(local) = stmt
+ && let Some(init) = &local.init
+ && let syn::Pat::Ident(pat_id) = &local.pat
+ && pat_id.mutability.is_some()
+ && let syn::Expr::Call(call) = &*init.expr
+ && let syn::Expr::Path(expr_path) = call.func.as_ref()
+ {
+ let segs = &expr_path.path.segments;
+ let matches = match segs.len() {
+ 2 => segs[0].ident == "RenderResult" && segs[1].ident == "from",
+ 3 => {
+ segs[0].ident == "mingling"
+ && segs[1].ident == "RenderResult"
+ && segs[2].ident == "from"
+ }
+ _ => false,
+ };
+ if matches {
+ return Some((pat_id.ident.clone(), i));
+ }
+ }
+ }
+ None
+}
+
+fn check_stmt_usage(stmt: &syn::Stmt, r_name: &str, print_count: &mut usize) -> bool {
+ // return r; → allowed
+ if let syn::Stmt::Expr(expr, _) = stmt
+ && let syn::Expr::Return(ret) = expr
+ && let Some(ret_expr) = &ret.expr
+ && let syn::Expr::Path(p) = ret_expr.as_ref()
+ && p.path.is_ident(r_name)
+ {
+ return true;
+ }
+
+ // r_println!(r, ...) → allowed
+ if let syn::Stmt::Macro(stmt_mac) = stmt {
+ let macro_name = stmt_mac
+ .mac
+ .path
+ .segments
+ .last()
+ .map(|s| s.ident.to_string())
+ .unwrap_or_default();
+ let is_r_macro = matches!(
+ macro_name.as_str(),
+ "r_println" | "r_eprintln" | "r_print" | "r_eprint" | "r_append"
+ );
+ if is_r_macro
+ && let Some(first_arg) = stmt_mac.mac.tokens.clone().into_iter().next()
+ && first_arg.to_string() == *r_name
+ {
+ *print_count += 1;
+ return true;
+ }
+ }
+
+ // Any other reference to r → not allowed
+ // Check the token stream for the variable name
+ let ts_string = stmt.to_token_stream().to_string();
+ if ts_string.contains(&format!("({r_name})"))
+ || ts_string.contains(&format!(" {r_name})"))
+ || ts_string.contains(&format!("(&mut {r_name})"))
+ || ts_string.contains(&format!("&{r_name}"))
+ || ts_string.contains(&format!("move {r_name}"))
+ || ts_string.contains(&format!(",{r_name},"))
+ {
+ return false;
+ }
+ true
+}
+
+#[cfg(test)]
+mod lint_test {
+ use crate::{assert_detected, assert_not_detected};
+
+ #[test]
+ fn test_detected_render_result_new() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[renderer]
+ fn render_somesthing(_: Prev) -> RenderResult {
+ let mut r = RenderResult::new();
+ r_println!(r, "");
+ r
+ }
+ });
+ }
+
+ #[test]
+ fn test_detected_render_result_default() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[renderer]
+ fn render_somesthing(_: Prev) -> RenderResult {
+ let mut r = RenderResult::default();
+ r_println!(r, "");
+ r
+ }
+ });
+ }
+
+ #[test]
+ fn test_detected_render_result_from() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[renderer]
+ fn render_somesthing(_: Prev) -> RenderResult {
+ let mut r = RenderResult::from("Hello".to_string());
+ r_println!(r, "");
+ r
+ }
+ });
+ }
+
+ #[test]
+ fn test_not_detected_with_other_function_call() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[renderer]
+ fn render_somesthing(_: Prev) -> RenderResult {
+ let mut r = RenderResult::new();
+ r_println!(r, "");
+ other(&mut r);
+ r
+ }
+ });
+ }
+
+ #[test]
+ fn test_not_detected_without_renderer_attr() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ fn render_somesthing(_: Prev) -> RenderResult {
+ let mut r = RenderResult::new();
+ r_println!(r, "");
+ r
+ }
+ });
+ }
+
+ #[test]
+ fn test_not_detected_with_buffer_attr() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::renderer(::mingling::macros::buffer)]
+ fn render_somesthing(_: Prev) {
+ r_println!("");
+ }
+ });
+ }
+
+ #[test]
+ fn test_not_detected_with_buffer_attr_fully_qualified() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::renderer(::mingling::macros::buffer)]
+ fn render_somesthing(_: Prev) {
+ r_println!("");
+ }
+ });
+ }
+}
diff --git a/mingling_cli/src/main.rs b/mingling_cli/src/main.rs
new file mode 100644
index 0000000..c822a12
--- /dev/null
+++ b/mingling_cli/src/main.rs
@@ -0,0 +1,29 @@
+use crate::{linter::MinglingLinterSetup, metadata::MinglingMetadataSetup};
+use mingling::{
+ macros::gen_program,
+ setup::{ExitCodeSetup, picker::HelpFlagSetup},
+};
+
+pub mod diagnostic;
+pub mod errors;
+pub mod linter;
+pub mod lints;
+pub mod message;
+pub mod metadata;
+
+#[tokio::main]
+async fn main() {
+ let mut program = ThisProgram::new();
+
+ // Setups
+ program.with_setup(HelpFlagSetup::default());
+ program.with_setup(ExitCodeSetup::default());
+
+ program.with_setup(MinglingMetadataSetup);
+ program.with_setup(MinglingLinterSetup);
+
+ // Exec
+ program.exec_and_exit().await;
+}
+
+gen_program!();
diff --git a/mingling_cli/src/message.rs b/mingling_cli/src/message.rs
new file mode 100644
index 0000000..3e50c30
--- /dev/null
+++ b/mingling_cli/src/message.rs
@@ -0,0 +1,10 @@
+use cargo_metadata::Message;
+use mingling::macros::{buffer, group, r_println, renderer, renderify};
+
+group!(Message);
+
+#[renderer(buffer, renderify)]
+pub fn render_message(msg: Message) {
+ let r = serde_json::to_string(&msg)?;
+ r_println!("{}", r);
+}
diff --git a/mingling_cli/src/metadata.rs b/mingling_cli/src/metadata.rs
new file mode 100644
index 0000000..2e7aeef
--- /dev/null
+++ b/mingling_cli/src/metadata.rs
@@ -0,0 +1,24 @@
+use cargo_metadata::Metadata;
+use mingling::{
+ Program,
+ macros::{buffer, group, program_setup, r_println, renderer, renderify},
+};
+
+use crate::metadata::{cmd_metadata::CMDMetadata, setup::CargoMetadataSetup};
+
+pub mod cmd_metadata;
+pub mod setup;
+
+#[program_setup]
+pub fn mingling_metadata_setup(program: &mut Program<crate::ThisProgram>) {
+ program.with_setup(CargoMetadataSetup);
+ program.with_dispatcher(CMDMetadata);
+}
+
+group!(Metadata);
+
+#[renderer(buffer, renderify)]
+pub fn render_metadata(metadata: Metadata) {
+ let result = serde_json::to_string(&metadata)?;
+ r_println!("{}", result);
+}
diff --git a/mingling_cli/src/metadata/cmd_metadata.rs b/mingling_cli/src/metadata/cmd_metadata.rs
new file mode 100644
index 0000000..18241a2
--- /dev/null
+++ b/mingling_cli/src/metadata/cmd_metadata.rs
@@ -0,0 +1,17 @@
+use cargo_metadata::Metadata;
+use mingling::{
+ LazyRes,
+ macros::{chain, dispatcher, pack},
+};
+
+use crate::metadata::setup::ResMetadata;
+
+dispatcher!("metadata");
+
+pack!(ResultMetadata = ResMetadata);
+
+#[chain]
+pub fn handle_metadata(_: EntryMetadata, metadata: &mut LazyRes<ResMetadata>) -> Metadata {
+ let metadata = metadata.get_ref().clone();
+ metadata.data().clone()
+}
diff --git a/mingling_cli/src/metadata/setup.rs b/mingling_cli/src/metadata/setup.rs
new file mode 100644
index 0000000..198baf1
--- /dev/null
+++ b/mingling_cli/src/metadata/setup.rs
@@ -0,0 +1,136 @@
+use std::{env::current_dir, path::PathBuf};
+
+use cargo_metadata::{CargoOpt, MetadataCommand};
+use mingling::{
+ LazyInit, Program,
+ macros::program_setup,
+ picker::{IntoPicker, value::Flag},
+ prelude::*,
+ res::ResCurrentDir,
+};
+
+/// Name of Cargo manifest file
+pub const CARGO_TOML: &str = "Cargo.toml";
+
+use cargo_metadata::Metadata;
+use serde::Serialize;
+
+/// Resource holding parsed `cargo metadata` output.
+///
+/// This is lazily initialized during program setup by calling `cargo metadata`
+/// with the appropriate CLI flags (e.g., `--features`, `--all-features`,
+/// `--no-default-features`, `--no-deps`, `--message-format`).
+///
+/// Access the inner [`Metadata`] via [`ResMetadata::data()`], which panics if
+/// called before initialization (guaranteed not to happen in normal usage).
+#[derive(Default, Clone, Serialize)]
+pub struct ResMetadata {
+ data: Option<Metadata>,
+}
+
+/// Resource indicating whether the output format is JSON.
+///
+/// Set to `true` when `--message-format json` (or similar) is passed.
+/// Used by renderers to decide whether to serialize structs as JSON.
+#[derive(Default, Clone, Serialize)]
+pub struct ResUsingJson {
+ pub using: bool,
+}
+
+impl ResMetadata {
+ /// Returns a reference to the parsed `cargo metadata`.
+ ///
+ /// # Panics
+ ///
+ /// This function does **not** panic in practice, because `ResMetadata` is
+ /// always initialized via [`LazyInit`] inside the program setup, and the
+ /// initialization either succeeds (setting `data` to `Some(...)`) or fails
+ /// by propagating the error from `cmd.exec().unwrap()`. Therefore, by the
+ /// time this getter is called, `self.data` is guaranteed to be `Some`.
+ pub fn data(&self) -> &Metadata {
+ self.data.as_ref().unwrap()
+ }
+}
+
+#[program_setup]
+pub fn cargo_metadata_setup(program: &mut Program<crate::ThisProgram>) {
+ let args = program.get_args().to_vec();
+
+ let (
+ feature_args,
+ manifest_path,
+ message_format,
+ enable_all_features,
+ no_default_features,
+ no_deps,
+ ) = args
+ .pick(&arg![features: Vec<String>])
+ .pick(&arg![manifest_path: Option<String>])
+ .pick_or(&arg![message_format: String], || "disable".to_string())
+ .pick(&arg![all_features: Flag])
+ .pick(&arg![no_default_features: Flag])
+ .pick(&arg![no_deps: Flag])
+ .unwrap();
+
+ // Is Using Json
+ program.with_resource(ResUsingJson {
+ using: message_format.contains("json"),
+ });
+
+ // Current Dir
+ let current_dir = current_dir().unwrap();
+
+ // Leak `feature_args` into a static slice so it can be captured by the metadata closure.
+ // Since the process does not loop, this memory leak is harmless.
+ let feature_args_leaked: &[String] = Box::leak(feature_args.into_boxed_slice());
+ let manifest_path_clone = manifest_path.clone();
+ let current_dir_clone = current_dir.clone();
+
+ program.with_resource(ResMetadata::lazy_init(move || {
+ // Paths
+ let metadata_path = match manifest_path_clone {
+ Some(ref path_str) => PathBuf::from(path_str),
+ None => find_manifest().unwrap(),
+ };
+
+ // Cargo Metadata - bind to a longer-lived variable
+ let mut cmd = MetadataCommand::new();
+ cmd.manifest_path(metadata_path)
+ .current_dir(&current_dir_clone);
+
+ if *enable_all_features {
+ cmd.features(CargoOpt::AllFeatures);
+ }
+
+ if *no_default_features {
+ cmd.features(CargoOpt::NoDefaultFeatures);
+ }
+
+ if *no_deps {
+ cmd.no_deps();
+ }
+
+ cmd.features(CargoOpt::SomeFeatures(feature_args_leaked.to_vec()));
+
+ ResMetadata {
+ data: Some(cmd.exec().unwrap()),
+ }
+ }));
+
+ // Current Dir
+ program.with_resource(ResCurrentDir::from(current_dir));
+}
+
+/// Find `Cargo.toml` by searching upward from `current_dir`.
+fn find_manifest() -> Option<PathBuf> {
+ let mut dir = current_dir().ok()?;
+ loop {
+ let candidate = dir.join(CARGO_TOML);
+ if candidate.is_file() {
+ return Some(candidate);
+ }
+ if !dir.pop() {
+ return None;
+ }
+ }
+}