use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Instant;
use clap::ColorChoice;
use clap::Parser;
use colored::Colorize;
use mago_database::DatabaseReader;
use mago_linter::registry::RuleRegistry;
use mago_linter::rule::AnyRule;
use mago_linter::rule_meta::RuleEntry;
use mago_orchestrator::service::lint::LintMode;
use mago_reporting::Level;
use crate::commands::args::baseline_reporting::BaselineReportingArgs;
use crate::commands::stdin_input;
use crate::config::Configuration;
use crate::error::Error;
use crate::utils::create_orchestrator;
use crate::utils::git;
#[derive(Parser, Debug)]
#[command(
name = "lint",
about = "Lints PHP source code for style, consistency, and structural errors.",
long_about = indoc::indoc! {"
Analyzes PHP files to find and report stylistic issues, inconsistencies, and
potential code quality improvements based on a configurable set of rules.
This is the primary tool for ensuring your codebase adheres to established
coding standards and best practices.
USAGE:
mago lint
mago lint src/
mago lint --list-rules
mago lint --explain no-empty
mago lint --only no-empty,constant-condition
By default, it lints all source paths defined in your `mago.toml` file. You can
also provide specific file or directory paths to lint a subset of your project.
"}
)]
pub struct LintCommand {
#[arg()]
pub path: Vec<PathBuf>,
#[arg(long, short = 's', conflicts_with_all = ["list_rules", "explain", "only"])]
pub semantics: bool,
#[arg(long)]
pub pedantic: bool,
#[arg(
long,
conflicts_with_all = ["list_rules", "sort", "fixable_only", "semantics", "reporting_target", "reporting_format"]
)]
pub explain: Option<String>,
#[arg(
long,
conflicts_with_all = ["explain", "sort", "fixable_only", "semantics", "reporting_target", "reporting_format"]
)]
pub list_rules: bool,
#[arg(long, requires = "list_rules")]
pub json: bool,
#[arg(short, long, conflicts_with = "semantics", num_args = 1.., value_delimiter = ',')]
pub only: Vec<String>,
#[arg(long, conflicts_with_all = ["path", "list_rules", "explain"])]
pub staged: bool,
#[arg(long, conflicts_with_all = ["list_rules", "explain", "staged"])]
pub stdin_input: bool,
#[clap(flatten)]
pub baseline_reporting: BaselineReportingArgs,
}
impl LintCommand {
pub fn execute(self, mut configuration: Configuration, color_choice: ColorChoice) -> Result<ExitCode, Error> {
let trace_enabled = tracing::enabled!(tracing::Level::TRACE);
let command_start = trace_enabled.then(Instant::now);
let editor_url = configuration.editor_url.take();
let orchestrator_init_start = trace_enabled.then(Instant::now);
let mut orchestrator = create_orchestrator(&configuration, color_choice, self.pedantic, true, false);
orchestrator.add_exclude_patterns(configuration.linter.excludes.iter());
let stdin_override = stdin_input::resolve_stdin_override(
self.stdin_input,
&self.path,
&configuration.source.workspace,
&mut orchestrator,
)?;
if !self.stdin_input && self.staged {
let staged_paths = git::get_staged_file_paths(&configuration.source.workspace)?;
if staged_paths.is_empty() {
tracing::info!("No staged files to lint.");
return Ok(ExitCode::SUCCESS);
}
if self.baseline_reporting.reporting.fix {
git::ensure_staged_files_are_clean(&configuration.source.workspace, &staged_paths)?;
}
orchestrator.set_source_paths(staged_paths.iter().map(|p| p.to_string_lossy().to_string()));
} else if !self.stdin_input && !self.path.is_empty() {
stdin_input::set_source_paths_from_paths(&mut orchestrator, &self.path);
}
let orchestrator_init_duration = orchestrator_init_start.map(|s| s.elapsed());
let load_database_start = trace_enabled.then(Instant::now);
let mut database = orchestrator.load_database(&configuration.source.workspace, false, None, stdin_override)?;
let load_database_duration = load_database_start.map(|s| s.elapsed());
let service = orchestrator.get_lint_service(database.read_only());
if let Some(explain_code) = self.explain {
let registry = service.create_registry(
if self.only.is_empty() { None } else { Some(&self.only) },
self.pedantic, );
return explain_rule(®istry, &explain_code);
}
if self.list_rules {
let registry = service.create_registry(
if self.only.is_empty() { None } else { Some(&self.only) },
self.pedantic, );
return list_rules(registry.rules(), self.json);
}
if database.is_empty() {
tracing::info!("No files found to lint.");
return Ok(ExitCode::SUCCESS);
}
let lint_run_start = trace_enabled.then(Instant::now);
let issues = service.lint(
if self.semantics { LintMode::SemanticsOnly } else { LintMode::Full },
if self.only.is_empty() { None } else { Some(self.only.as_slice()) },
)?;
let lint_run_duration = lint_run_start.map(|s| s.elapsed());
let report_start = trace_enabled.then(Instant::now);
let baseline = configuration.linter.baseline.as_deref();
let baseline_variant = configuration.linter.baseline_variant;
let processor = self.baseline_reporting.get_processor(
color_choice,
baseline,
baseline_variant,
editor_url,
configuration.linter.minimum_fail_level,
);
let (exit_code, changed_file_ids) = processor.process_issues(&orchestrator, &mut database, issues)?;
let report_duration = report_start.map(|s| s.elapsed());
if self.staged && !changed_file_ids.is_empty() {
git::stage_files(&configuration.source.workspace, &database, changed_file_ids)?;
}
let drop_database_start = trace_enabled.then(Instant::now);
drop(database);
let drop_database_duration = drop_database_start.map(|s| s.elapsed());
let drop_orchestrator_start = trace_enabled.then(Instant::now);
drop(orchestrator);
let drop_orchestrator_duration = drop_orchestrator_start.map(|s| s.elapsed());
if let Some(start) = command_start {
tracing::trace!("Orchestrator initialized in {:?}.", orchestrator_init_duration.unwrap_or_default());
tracing::trace!("Database loaded in {:?}.", load_database_duration.unwrap_or_default());
tracing::trace!("Lint service ran in {:?}.", lint_run_duration.unwrap_or_default());
tracing::trace!("Issues filtered and reported in {:?}.", report_duration.unwrap_or_default());
tracing::trace!("Database dropped in {:?}.", drop_database_duration.unwrap_or_default());
tracing::trace!("Orchestrator dropped in {:?}.", drop_orchestrator_duration.unwrap_or_default());
tracing::trace!("Lint command finished in {:?}.", start.elapsed());
}
Ok(exit_code)
}
}
pub fn explain_rule(registry: &RuleRegistry, code: &str) -> Result<ExitCode, Error> {
let Some(rule) = registry.rules().iter().find(|r| r.meta().code == code) else {
println!();
println!(" {}", "Error: Rule not found".red().bold());
println!(" {}", format!("Could not find a rule with the code '{}'.", code).bright_black());
println!(" {}", "Please check the spelling and try again.".bright_black());
println!();
return Ok(ExitCode::FAILURE);
};
let meta = rule.meta();
println!();
println!(" ╭─ {} {}", "Rule".bold(), meta.name.cyan().bold());
println!(" │");
println!("{}", wrap_and_prefix(meta.description, " │ ", 80));
println!(" │");
println!(" │ {}: {}", "Code".bold(), meta.code.yellow());
println!(" │ {}: {}", "Category".bold(), meta.category.as_str().magenta());
if !meta.good_example.trim().is_empty() {
println!(" │");
println!(" │ {}", "✅ Good Example".green().bold());
println!(" │");
println!("{}", colorize_code_block(meta.good_example));
}
if !meta.bad_example.trim().is_empty() {
println!(" │");
println!(" │ {}", "🚫 Bad Example".red().bold());
println!(" │");
println!("{}", colorize_code_block(meta.bad_example));
}
println!(" │");
println!(" │ {}", "Try it out!".bold());
println!(" │ {}", format!("mago lint --only {}", meta.code).bright_black());
println!(" ╰─");
println!();
Ok(ExitCode::SUCCESS)
}
pub fn list_rules(rules: &[AnyRule], json: bool) -> Result<ExitCode, Error> {
if rules.is_empty() && !json {
println!("{}", "No rules are currently enabled.".yellow());
return Ok(ExitCode::SUCCESS);
}
if json {
let entries: Vec<_> = rules.iter().map(|r| RuleEntry { meta: r.meta(), level: r.default_level() }).collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(ExitCode::SUCCESS);
}
let max_name = rules.iter().map(|r| r.meta().name.len()).max().unwrap_or(0);
let max_code = rules.iter().map(|r| r.meta().code.len()).max().unwrap_or(0);
println!();
println!(
" {: <width_name$} {: <width_code$} {: <8} {}",
"Name".bold().underline(),
"Code".bold().underline(),
"Level".bold().underline(),
"Category".bold().underline(),
width_name = max_name,
width_code = max_code,
);
println!();
for rule in rules {
let meta = rule.meta();
let level_str = match rule.default_level() {
Level::Error => "Error".red(),
Level::Warning => "Warning".yellow(),
Level::Help => "Help".green(),
Level::Note => "Note".blue(),
};
println!(
" {: <width_name$} {: <width_code$} {: <8} {}",
meta.name.cyan(),
meta.code.bright_black(),
level_str.bold(),
meta.category.as_str().magenta(),
width_name = max_name,
width_code = max_code,
);
}
println!();
println!(" Run {} to see more information about a specific rule.", "mago lint --explain <CODE>".bold());
println!();
Ok(ExitCode::SUCCESS)
}
fn colorize_code_block(code: &str) -> String {
let mut colored_code = String::new();
for line in code.trim().lines() {
let trimmed_line = line.trim_start();
let indentation = &line[..line.len() - trimmed_line.len()];
let colored_line =
if trimmed_line.starts_with("<?php") || trimmed_line.starts_with("<?") || trimmed_line.starts_with("?>") {
trimmed_line.yellow().bold().to_string()
} else {
trimmed_line.to_string()
};
colored_code.push_str(&format!(" │ {}{}\n", indentation.bright_black(), colored_line));
}
colored_code.trim_end().to_string()
}
fn wrap_and_prefix(text: &str, prefix: &str, width: usize) -> String {
let mut result = String::new();
let wrap_width = width.saturating_sub(prefix.len());
for (i, paragraph) in text.trim().split("\n\n").enumerate() {
if i > 0 {
result.push_str(prefix);
result.push('\n');
}
let mut current_line = String::new();
for word in paragraph.split_whitespace() {
if !current_line.is_empty() && current_line.len() + word.len() + 1 > wrap_width {
result.push_str(prefix);
result.push_str(¤t_line);
result.push('\n');
current_line.clear();
}
if !current_line.is_empty() {
current_line.push(' ');
}
current_line.push_str(word);
}
if !current_line.is_empty() {
result.push_str(prefix);
result.push_str(¤t_line);
result.push('\n');
}
}
result.trim_end().to_string()
}