use std::collections::BTreeMap;
use std::fmt::Write;
use std::path::Path;
use std::path::PathBuf;
use clap::ArgMatches;
use monochange_config::load_workspace_configuration;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::lint::LintPreset;
use monochange_core::lint::LintProgressReporter;
use monochange_core::lint::LintReport;
use monochange_core::lint::LintRule;
use monochange_core::lint::LintSeverity;
use monochange_core::lint::LintSuite;
use monochange_lint::LintSelection;
use monochange_lint::Linter;
use crate::OutputFormat;
#[allow(clippy::vec_init_then_push)]
fn lint_suites() -> Vec<Box<dyn LintSuite>> {
let mut suites: Vec<Box<dyn LintSuite>> = Vec::new();
#[cfg(feature = "cargo")]
suites.push(Box::new(monochange_cargo::lints::lint_suite()));
#[cfg(feature = "npm")]
suites.push(Box::new(monochange_npm::lints::lint_suite()));
#[cfg(feature = "dart")]
suites.push(Box::new(monochange_dart::lints::lint_suite()));
suites.push(Box::new(monochange_config::lints::lint_suite()));
suites
}
fn build_linter(
configuration: &monochange_core::WorkspaceConfiguration,
selection: LintSelection,
) -> Linter {
Linter::new(lint_suites(), configuration.lints.clone()).with_selection(selection)
}
pub(crate) fn collect_workspace_validation_issues(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
) -> (Vec<String>, Vec<String>) {
let mut warnings = Vec::new();
let mut errors = Vec::new();
if let Err(error) = monochange_config::validate_workspace_with_config(root, configuration) {
errors.push(error.render());
}
match monochange_config::validate_versioned_files_content_with_config(root, configuration) {
Ok(mut collected_warnings) => warnings.append(&mut collected_warnings),
Err(error) => errors.push(error.render()),
}
#[cfg(feature = "cargo")]
if let Err(error) = crate::workspace_ops::validate_cargo_workspace_version_groups(root) {
errors.push(error.render());
}
(warnings, errors)
}
pub(crate) fn available_lint_rules() -> Vec<LintRule> {
let mut rules = Linter::new(
lint_suites(),
monochange_core::lint::WorkspaceLintSettings::default(),
)
.registry()
.rules();
rules.sort_by(|left, right| left.id.cmp(&right.id));
rules
}
pub(crate) fn available_lint_presets() -> Vec<LintPreset> {
let mut presets = Linter::new(
lint_suites(),
monochange_core::lint::WorkspaceLintSettings::default(),
)
.registry()
.presets();
presets.sort_by(|left, right| left.id.cmp(&right.id));
presets
}
pub(crate) fn explain_lint_rule(rule_id: &str) -> Option<LintRule> {
Linter::new(
lint_suites(),
monochange_core::lint::WorkspaceLintSettings::default(),
)
.registry()
.find_rule(rule_id)
}
pub(crate) fn explain_lint_preset(preset_id: &str) -> Option<LintPreset> {
Linter::new(
lint_suites(),
monochange_core::lint::WorkspaceLintSettings::default(),
)
.registry()
.find_preset(preset_id)
}
pub(crate) fn run_check_command(
root: &Path,
fix: bool,
ecosystems: &[String],
only_rules: &[String],
format: OutputFormat,
verbose: bool,
) -> MonochangeResult<String> {
let configuration = load_workspace_configuration(root)?;
let mut output = String::new();
let show_progress = matches!(format, OutputFormat::Text | OutputFormat::Markdown);
if show_progress {
eprintln!("\u{2139} Validating workspace…");
}
let (validation_warnings, validation_errors) =
collect_workspace_validation_issues(root, &configuration);
for warning in &validation_warnings {
let _ = writeln!(output, "warning: {warning}");
}
if validation_errors.is_empty() {
let _ = writeln!(output, "workspace validation passed for {}", root.display());
} else {
let _ = writeln!(output, "workspace validation failed for {}", root.display());
for error in &validation_errors {
let _ = writeln!(output, "{error}");
}
}
let selection = LintSelection::all()
.with_suites(ecosystems.iter().cloned())
.with_rules(only_rules.iter().cloned());
let linter = build_linter(&configuration, selection);
let show_progress = matches!(format, OutputFormat::Text | OutputFormat::Markdown);
let reporter = if show_progress {
Some(crate::lint_check_reporter::HumanLintProgressReporter::new())
} else {
None
};
let report = if let Some(ref r) = reporter {
linter.lint_workspace(root, &configuration, r)
} else {
linter.lint_workspace(
root,
&configuration,
&monochange_core::lint::NoopLintProgressReporter,
)
};
let mut fixed_files: Vec<(PathBuf, String)> = Vec::new();
if fix {
let fixes = linter.apply_fixes(&report);
if let Some(ref r) = reporter {
r.fix_started(fixes.len());
}
for (file_path, fixed_content) in fixes {
std::fs::write(&file_path, fixed_content).map_err(|error| {
MonochangeError::Io(format!(
"Failed to write fixed content to {}: {}",
file_path.display(),
error
))
})?;
if let Some(ref r) = reporter {
let description = report
.autofixable()
.iter()
.find(|res| res.location.file_path == file_path)
.and_then(|res| res.fix.as_ref())
.map_or("fixed", |f| f.description.as_str());
fixed_files.push((file_path.clone(), description.to_string()));
r.fix_applied(&file_path, description);
}
}
if let Some(ref r) = reporter {
r.fix_finished(fixed_files.len());
}
}
let lint_has_errors = report.has_errors();
let validation_has_errors = !validation_errors.is_empty();
if let Some(r) = reporter {
r.summary(
report.error_count,
report.warning_count,
report.autofixable().len(),
fix,
);
r.finish();
}
match format {
OutputFormat::Json => {
if validation_has_errors {
return Err(MonochangeError::Config(format!("check failed:\n{output}")));
}
Ok(serde_json::to_string_pretty(&report)
.unwrap_or_else(|error| panic!("serializing lint reports should succeed: {error}")))
}
OutputFormat::Text | OutputFormat::Markdown => {
output.push_str(&format_check_report(&report, fix, verbose));
if validation_has_errors || lint_has_errors {
Err(MonochangeError::Config(format!("check failed:\n{output}")))
} else {
Ok(output)
}
}
}
}
#[allow(dead_code)]
pub(crate) fn run_lint_step(root: &Path, fix: bool) -> MonochangeResult<(String, bool)> {
let configuration = load_workspace_configuration(root)?;
let linter = build_linter(&configuration, LintSelection::all());
let report = linter.lint_workspace(
root,
&configuration,
&monochange_core::lint::NoopLintProgressReporter,
);
let has_errors = report.has_errors();
if fix {
let fixes = linter.apply_fixes(&report);
for (file_path, fixed_content) in fixes {
std::fs::write(&file_path, fixed_content).map_err(|error| {
MonochangeError::Io(format!(
"Failed to write fixed content to {}: {}",
file_path.display(),
error
))
})?;
}
}
Ok((format_check_report(&report, fix, false), has_errors))
}
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn render_lint_catalog(format: OutputFormat) -> MonochangeResult<String> {
let rules = available_lint_rules();
let presets = available_lint_presets();
match format {
OutputFormat::Json => {
Ok(serde_json::to_string_pretty(&serde_json::json!({
"rules": rules,
"presets": presets,
}))
.unwrap_or_else(|error| panic!("serializing lint catalog should succeed: {error}")))
}
OutputFormat::Text | OutputFormat::Markdown => {
let mut output = String::new();
output.push_str("Rules:\n");
for rule in rules {
let _ = writeln!(
output,
"- {} [{:?} {:?}]{}",
rule.id,
rule.category,
rule.maturity,
if rule.autofixable { " [fixable]" } else { "" }
);
let _ = writeln!(output, " {}", rule.description);
}
output.push_str("\nPresets:\n");
for preset in presets {
let _ = writeln!(output, "- {} [{:?}]", preset.id, preset.maturity);
let _ = writeln!(output, " {}", preset.description);
}
Ok(output)
}
}
}
pub(crate) fn render_lint_explanation(id: &str, format: OutputFormat) -> MonochangeResult<String> {
if let Some(rule) = explain_lint_rule(id) {
return match format {
OutputFormat::Json => {
Ok(serde_json::to_string_pretty(&rule).unwrap_or_else(|error| {
panic!("serializing lint rule explanations should succeed: {error}")
}))
}
OutputFormat::Text | OutputFormat::Markdown => {
let mut output = String::new();
let _ = writeln!(output, "{}", rule.id);
let _ = writeln!(output, "name: {}", rule.name);
let _ = writeln!(output, "category: {:?}", rule.category);
let _ = writeln!(output, "maturity: {:?}", rule.maturity);
let _ = writeln!(output, "autofixable: {}", rule.autofixable);
let _ = writeln!(output, "\n{}", rule.description);
for (index, option) in rule.options.into_iter().enumerate() {
if index == 0 {
output.push_str("\nOptions:\n");
}
let _ = writeln!(output, "- {} ({:?})", option.name, option.kind);
let _ = writeln!(output, " {}", option.description);
}
Ok(output)
}
};
}
if let Some(preset) = explain_lint_preset(id) {
return match format {
OutputFormat::Json => {
Ok(
serde_json::to_string_pretty(&preset).unwrap_or_else(|error| {
panic!("serializing lint preset explanations should succeed: {error}")
}),
)
}
OutputFormat::Text | OutputFormat::Markdown => {
let mut output = String::new();
let _ = writeln!(output, "{}", preset.id);
let _ = writeln!(output, "name: {}", preset.name);
let _ = writeln!(output, "maturity: {:?}", preset.maturity);
let _ = writeln!(output, "\n{}", preset.description);
output.push_str("\nRules:\n");
for (rule_id, config) in preset.rules {
let _ = writeln!(output, "- {} = {}", rule_id, config.severity());
}
Ok(output)
}
};
}
Err(MonochangeError::Config(format!(
"unknown lint rule or preset `{id}`"
)))
}
pub(crate) fn handle_lint_subcommand(
root: &Path,
lint_matches: &ArgMatches,
) -> MonochangeResult<String> {
let (subcommand, subcommand_matches) = lint_matches
.subcommand()
.expect("clap requires a lint subcommand");
if subcommand == "list" {
let format = subcommand_matches
.get_one::<String>("format")
.map_or(Ok(OutputFormat::Markdown), |value| {
crate::parse_output_format(value)
})?;
return render_lint_catalog(format);
}
if subcommand == "explain" {
let format = subcommand_matches
.get_one::<String>("format")
.map_or(Ok(OutputFormat::Markdown), |value| {
crate::parse_output_format(value)
})?;
let id = subcommand_matches
.get_one::<String>("id")
.expect("clap requires a lint id")
.as_str();
return render_lint_explanation(id, format);
}
let id = subcommand_matches
.get_one::<String>("id")
.expect("clap requires a lint id")
.as_str();
scaffold_lint_rule(root, id)
}
pub(crate) fn scaffold_lint_rule(root: &Path, id: &str) -> MonochangeResult<String> {
let (suite, rule_name) = id.split_once('/').ok_or_else(|| {
MonochangeError::Config("lint ids must use the form <ecosystem>/<rule-name>".to_string())
})?;
let crate_name = match suite {
"cargo" => "monochange_cargo",
"npm" => "monochange_npm",
"dart" => "monochange_dart",
other => {
return Err(MonochangeError::Config(format!(
"scaffolding is not yet supported for lint suite `{other}`"
)));
}
};
let module_name = rule_name.replace('-', "_");
let struct_name = rule_name
.split('-')
.map(|segment| {
let mut chars = segment.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<String>()
+ "Rule";
let lint_dir = root.join("crates").join(crate_name).join("src/lints");
std::fs::create_dir_all(&lint_dir).map_err(|error| {
MonochangeError::Io(format!(
"failed to create lint directory {}: {error}",
lint_dir.display()
))
})?;
let lint_file = lint_dir.join(format!("{module_name}.rs"));
if lint_file.exists() {
return Err(MonochangeError::Config(format!(
"lint file {} already exists",
lint_file.display()
)));
}
let fixture_dir = root
.join("fixtures/tests/lints")
.join(suite)
.join(rule_name)
.join("workspace");
std::fs::create_dir_all(&fixture_dir).map_err(|error| {
MonochangeError::Io(format!(
"failed to create lint fixture directory {}: {error}",
fixture_dir.display()
))
})?;
let template = format!(
r#"use monochange_core::lint::LintContext;
use monochange_core::lint::LintResult;
use monochange_core::lint::LintRule;
use monochange_core::lint::LintRuleConfig;
use monochange_core::lint::LintRuleRunner;
use monochange_linting::declare_lint_rule;
use monochange_linting::LintCategory;
use monochange_linting::LintMaturity;
// Keep `declare_lint_rule!` for straightforward metadata-only construction.
// If this rule later needs extra constructor state, switch to an explicit
// `struct` plus `LintRule::new(...)`.
declare_lint_rule! {{
pub {struct_name},
id: "{suite}/{rule_name}",
name: "TODO: rename me",
description: "TODO: describe what this lint checks",
category: LintCategory::BestPractice,
maturity: LintMaturity::Experimental,
autofixable: false,
}}
impl LintRuleRunner for {struct_name} {{
fn rule(&self) -> &LintRule {{
&self.rule
}}
fn run(&self, _ctx: &LintContext<'_>, _config: &LintRuleConfig) -> Vec<LintResult> {{
Vec::new()
}}
}}
"#
);
std::fs::write(&lint_file, template).map_err(|error| {
MonochangeError::Io(format!(
"failed to write lint file {}: {error}",
lint_file.display()
))
})?;
let note_file = fixture_dir.join("README.md");
std::fs::write(
¬e_file,
format!("# {id}\n\nAdd fixture workspaces for snapshot and autofix tests here.\n"),
)
.map_err(|error| {
MonochangeError::Io(format!(
"failed to write fixture note {}: {error}",
note_file.display()
))
})?;
Ok(format!(
"Created {} and {}.\nNext steps:\n- wire `mod {module_name};` into `crates/{crate_name}/src/lints/mod.rs`\n- register `{struct_name}::new()` in the suite\n- add fixture scenarios under {}",
lint_file.display(),
note_file.display(),
fixture_dir.display()
))
}
fn format_check_report(report: &LintReport, fixed: bool, verbose: bool) -> String {
if report.results.is_empty() && report.warnings.is_empty() {
return "lint: no issues found\n".to_string();
}
let mut output = String::new();
let _ = write!(
output,
"lint: {} errors, {} warnings\n\n",
report.error_count, report.warning_count
);
for warning in &report.warnings {
let _ = writeln!(output, "warning: {warning}");
}
if !report.warnings.is_empty() {
output.push('\n');
}
let mut by_file: BTreeMap<&Path, Vec<&monochange_core::lint::LintResult>> = BTreeMap::new();
for result in &report.results {
by_file
.entry(&result.location.file_path)
.or_default()
.push(result);
}
for (file, results) in by_file {
let _ = writeln!(output, "{}:", file.display());
for result in results {
let severity_icon = match result.severity {
LintSeverity::Error => "✗",
LintSeverity::Warning => "⚠",
LintSeverity::Off => "·",
};
let fix_indicator = if result.fix.is_some() {
" [fixable]"
} else {
""
};
let _ = writeln!(
output,
" {} **{}** at {}:{}{}",
severity_icon,
result.rule_id,
result.location.line,
result.location.column,
fix_indicator
);
let _ = writeln!(output, " {}", result.message);
if verbose {
let _ = writeln!(output, " severity: {}", result.severity);
if let Some((start, end)) = result.location.span {
let _ = writeln!(output, " span: {start}..{end}");
}
if let Some(fix) = result.fix.as_ref() {
let _ = writeln!(output, " fix: {}", fix.description);
}
}
}
output.push('\n');
}
if fixed {
output.push_str("Fixed all auto-fixable issues.\n");
} else if report.autofixable().is_empty() {
output.push_str("No auto-fixable issues found.\n");
} else {
let _ = writeln!(
output,
"{} issue(s) can be auto-fixed. Run with --fix to apply.",
report.autofixable().len()
);
}
output
}
#[cfg(test)]
#[path = "__tests__/lint_tests.rs"]
mod tests;