diff options
Diffstat (limited to 'mingling_cli/src')
| -rw-r--r-- | mingling_cli/src/diagnostic.rs | 99 | ||||
| -rw-r--r-- | mingling_cli/src/errors.rs | 1 | ||||
| -rw-r--r-- | mingling_cli/src/errors/serde_json.rs | 8 | ||||
| -rw-r--r-- | mingling_cli/src/linter.rs | 67 | ||||
| -rw-r--r-- | mingling_cli/src/linter/cmd_mlint.rs | 119 | ||||
| -rw-r--r-- | mingling_cli/src/linter/mlint_attr.rs | 39 | ||||
| -rw-r--r-- | mingling_cli/src/linter/mlint_report.rs | 445 | ||||
| -rw-r--r-- | mingling_cli/src/lints.rs | 82 | ||||
| -rw-r--r-- | mingling_cli/src/lints/non_mingling_naming_style.rs | 352 | ||||
| -rw-r--r-- | mingling_cli/src/lints/template_linter.rs | 56 | ||||
| -rw-r--r-- | mingling_cli/src/lints/unnecessary_render_result_creation.rs | 271 | ||||
| -rw-r--r-- | mingling_cli/src/main.rs | 29 | ||||
| -rw-r--r-- | mingling_cli/src/message.rs | 10 | ||||
| -rw-r--r-- | mingling_cli/src/metadata.rs | 24 | ||||
| -rw-r--r-- | mingling_cli/src/metadata/cmd_metadata.rs | 17 | ||||
| -rw-r--r-- | mingling_cli/src/metadata/setup.rs | 136 |
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(¤t_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; + } + } +} |
