use std::borrow::Cow;
use std::path::Path;
use std::process::ExitCode;
use std::sync::Arc;
use bumpalo::Bump;
use clap::ColorChoice;
use mago_database::file::FileId;
use mago_syntax::parser::parse_file_content_with_settings;
use mago_syntax::settings::ParserSettings;
use rayon::iter::IntoParallelIterator;
use rayon::iter::ParallelIterator;
use mago_database::Database;
use mago_database::DatabaseReader;
use mago_database::ReadDatabase;
use mago_database::change::ChangeLog;
use mago_database::file::File;
use mago_orchestrator::Orchestrator;
use mago_orchestrator::service::format::FileFormatStatus;
use mago_reporting::ColorChoice as ReportingColorChoice;
use mago_reporting::IssueCollection;
use mago_reporting::Level;
use mago_reporting::ReportingFormat;
use mago_reporting::ReportingTarget;
use mago_reporting::baseline::Baseline;
use mago_reporting::baseline::BaselineVariant;
use mago_reporting::reporter::Reporter;
use mago_reporting::reporter::ReporterConfig;
use mago_text_edit::ApplyResult;
use mago_text_edit::Safety;
use mago_text_edit::TextEditor;
use crate::baseline;
use crate::baseline::unserialize_baseline;
use crate::consts::ISSUE_URL;
use crate::error::Error;
use crate::utils;
#[derive(Debug)]
pub struct IssueProcessor {
pub fixable_only: bool,
pub sort: bool,
pub fix: bool,
pub r#unsafe: bool,
pub potentially_unsafe: bool,
pub format_after_fix: bool,
pub dry_run: bool,
pub fail_on_remaining: bool,
pub reporting_target: ReportingTarget,
pub reporting_format: ReportingFormat,
pub minimum_fail_level: Level,
pub minimum_report_level: Option<Level>,
pub retain_code: Vec<String>,
pub color_choice: ColorChoice,
pub editor_url: Option<String>,
}
#[derive(Debug)]
pub struct BaselineIssueProcessor {
pub baseline_path: Option<Cow<'static, Path>>,
pub generate_baseline: bool,
pub backup_baseline: bool,
pub verify_baseline: bool,
pub fail_on_out_of_sync_baseline: bool,
pub baseline_variant: BaselineVariant,
pub issue_processor: IssueProcessor,
}
impl IssueProcessor {
pub fn process_issues(
&self,
orchestrator: &Orchestrator<'_>,
database: &mut Database<'_>,
issues: IssueCollection,
baseline: Option<Baseline>,
fail_on_out_of_sync_baseline: bool,
) -> Result<(ExitCode, Vec<FileId>), Error> {
if self.fix {
self.handle_fix_mode(orchestrator, database, issues, baseline)
} else {
self.handle_report_mode(database, issues, baseline, fail_on_out_of_sync_baseline)
.map(|code| (code, Vec::new()))
}
}
fn handle_fix_mode(
&self,
orchestrator: &Orchestrator<'_>,
database: &mut Database<'_>,
issues: IssueCollection,
baseline: Option<Baseline>,
) -> Result<(ExitCode, Vec<FileId>), Error> {
let issues =
if let Some(baseline) = baseline { baseline.filter_issues(issues, &database.read_only()) } else { issues };
let unfixable_count = (&issues).into_iter().filter(|i| i.edits.is_empty()).count();
let dry_run = self.dry_run;
let (applied_fixes, skipped_unsafe, skipped_potentially_unsafe, bugs, changed_file_ids) =
self.apply_fixes(orchestrator, database, issues)?;
if skipped_unsafe > 0 {
tracing::warn!("Skipped {skipped_unsafe} unsafe fixes. Use `--unsafe` to apply them.");
}
if skipped_potentially_unsafe > 0 {
tracing::warn!(
"Skipped {skipped_potentially_unsafe} potentially unsafe fixes. Use `--potentially-unsafe` or `--unsafe` to apply them.",
);
}
let success = {
if applied_fixes == 0 {
tracing::info!("No fixes were applied.");
true
} else if dry_run {
tracing::info!("Found {applied_fixes} fixes that can be applied (dry-run).");
false
} else {
tracing::info!("Successfully applied {applied_fixes} fixes.");
true
}
};
if bugs > 0 {
tracing::error!("Encountered {bugs} bugs while applying fixes. Please report them at {}", ISSUE_URL);
return Ok((ExitCode::FAILURE, changed_file_ids));
}
if success && self.fail_on_remaining {
let skipped_safety = skipped_unsafe + skipped_potentially_unsafe;
let remaining = unfixable_count + skipped_safety;
if remaining > 0 {
if unfixable_count > 0 {
tracing::warn!(
"{unfixable_count} issues require manual attention and cannot be fixed automatically."
);
}
if skipped_safety > 0 {
tracing::warn!(
"{skipped_safety} issues were skipped due to safety level. Use `--potentially-unsafe` or `--unsafe` to apply them.",
);
}
return Ok((ExitCode::FAILURE, changed_file_ids));
}
}
Ok((if success { ExitCode::SUCCESS } else { ExitCode::FAILURE }, changed_file_ids))
}
fn handle_report_mode<'d>(
&self,
database: &'d Database<'d>,
mut issues: IssueCollection,
baseline: Option<Baseline>,
fail_on_out_of_sync_baseline: bool,
) -> Result<ExitCode, Error> {
let read_database = database.read_only();
if !self.retain_code.is_empty() {
let total_before_filter = issues.len();
issues.filter_retain_codes(&self.retain_code);
let total_after_filter = issues.len();
let filtered_count = total_before_filter - total_after_filter;
let codes_list = self.retain_code.join(", ");
if total_after_filter == 0 && total_before_filter > 0 {
tracing::warn!("No issues found matching code(s): {}", codes_list);
} else if filtered_count > 0 {
tracing::info!(
"Retaining {} of {} issues with code(s): {}",
total_after_filter,
total_before_filter,
codes_list
);
}
}
let issues_to_report = issues;
let reporter_configuration = ReporterConfig {
target: self.reporting_target.clone(),
format: self.reporting_format,
color_choice: match self.color_choice {
ColorChoice::Auto => ReportingColorChoice::Auto,
ColorChoice::Always => ReportingColorChoice::Always,
ColorChoice::Never => ReportingColorChoice::Never,
},
filter_fixable: self.fixable_only,
sort: self.sort,
minimum_report_level: self.minimum_report_level,
editor_url: self.editor_url.clone(),
};
let reporter = Reporter::new(read_database, reporter_configuration);
let status = reporter.report(issues_to_report, baseline)?;
if status.baseline_dead_issues {
tracing::warn!(
"Your baseline file contains entries for issues that no longer exist. Consider regenerating it with `--generate-baseline`."
);
if fail_on_out_of_sync_baseline {
return Ok(ExitCode::FAILURE);
}
}
if status.baseline_filtered_issues > 0 {
tracing::info!("Filtered out {} issues based on the baseline file.", status.baseline_filtered_issues);
}
if let Some(highest_reported_level) = status.highest_reported_level
&& self.minimum_fail_level <= highest_reported_level
{
return Ok(ExitCode::FAILURE);
}
if status.total_reported_issues == 0 {
if self.fixable_only {
tracing::info!("No fixable issues found.");
} else {
tracing::info!("No issues found.");
}
}
Ok(ExitCode::SUCCESS)
}
fn apply_fixes(
&self,
orchestrator: &Orchestrator<'_>,
database: &mut Database<'_>,
issues: IssueCollection,
) -> Result<(usize, usize, usize, usize, Vec<FileId>), Error> {
let read_database = Arc::new(database.read_only());
let change_log = ChangeLog::new();
let safety_threshold = if self.r#unsafe {
Safety::Unsafe
} else if self.potentially_unsafe {
Safety::PotentiallyUnsafe
} else {
Safety::Safe
};
let batches_by_file = issues.to_edit_batches();
if batches_by_file.is_empty() {
return Ok((0, 0, 0, 0, Vec::new()));
}
let format_after_fix = self.format_after_fix;
let dry_run = self.dry_run;
let color_choice = self.color_choice;
let parser_settings = orchestrator.config.parser_settings;
let results: Vec<(bool, usize, usize, usize)> = batches_by_file
.into_par_iter()
.map_init(Bump::new, |arena, (file_id, batches)| {
let file = read_database.get_ref(&file_id)?;
let mut editor = TextEditor::with_safety(&file.contents, safety_threshold);
let checker = |code: &str| check_php_code(arena, file_id, code, parser_settings);
let mut skipped_unsafe = 0usize;
let mut skipped_potentially_unsafe = 0usize;
let mut bugs = 0usize;
for (rule_code, edits) in batches {
let rule_code = rule_code.as_deref().unwrap_or("unknown");
let result = editor.apply_batch(edits, Some(checker));
match result {
ApplyResult::Applied => {
}
ApplyResult::Unsafe => {
skipped_unsafe += 1;
}
ApplyResult::PotentiallyUnsafe => {
skipped_potentially_unsafe += 1;
}
ApplyResult::OutOfBounds => {
tracing::warn!("Edit out of bounds for `{}` (issue: `{rule_code}`). This is a bug in Mago.", file.name.as_ref());
tracing::error!("Please report this issue at {}", ISSUE_URL);
bugs += 1;
}
ApplyResult::Overlap => {
tracing::warn!("Overlapping edit for `{}` (issue: `{rule_code}`), skipping.", file.name.as_ref());
tracing::warn!("This can happen when multiple fixers modify the same code. Try running the fixer again to apply remaining fixes.");
}
ApplyResult::Rejected => {
tracing::error!("Edit for `{}` (issue: `{rule_code}`) was rejected because it would produce invalid PHP syntax.", file.name.as_ref());
tracing::error!("This is a bug in Mago. Please report this issue at {}", ISSUE_URL);
bugs += 1;
}
}
}
let fixed_content = editor.finish();
if fixed_content == file.contents {
return Ok((false, skipped_unsafe, skipped_potentially_unsafe, bugs));
}
let final_content = if format_after_fix {
let ephemeral_file = File::ephemeral(file.name.clone(), Cow::Owned(fixed_content));
let format_status = orchestrator.format_file_in(&ephemeral_file, arena)?;
match format_status {
FileFormatStatus::Unchanged => ephemeral_file.contents.into_owned(),
FileFormatStatus::Changed(new_content) => new_content,
FileFormatStatus::FailedToParse(parse_error) => {
tracing::warn!(
"Failed to format file `{}` after applying fixes: {}",
ephemeral_file.name.as_ref(),
parse_error
);
ephemeral_file.contents.into_owned()
}
}
} else {
fixed_content
};
arena.reset();
let changed = utils::apply_update(&change_log, file, final_content.as_ref(), dry_run, color_choice)?;
Ok((changed, skipped_unsafe, skipped_potentially_unsafe, bugs))
})
.collect::<Result<Vec<(bool, usize, usize, usize)>, Error>>()?;
let changed_file_ids = if !dry_run {
let ids = change_log.changed_file_ids()?;
database.commit(change_log, true)?;
ids
} else {
Vec::new()
};
let mut applied_fix_count = 0;
let mut total_skipped_unsafe = 0;
let mut total_skipped_potentially_unsafe = 0;
let mut total_bugs = 0;
for (changed, skipped_unsafe, skipped_potentially_unsafe, bugs) in results {
if changed {
applied_fix_count += 1;
}
total_skipped_unsafe += skipped_unsafe;
total_skipped_potentially_unsafe += skipped_potentially_unsafe;
total_bugs += bugs;
}
Ok((applied_fix_count, total_skipped_unsafe, total_skipped_potentially_unsafe, total_bugs, changed_file_ids))
}
}
impl BaselineIssueProcessor {
pub fn process_issues(
&self,
orchestrator: &Orchestrator<'_>,
database: &mut Database<'_>,
issues: IssueCollection,
) -> Result<(ExitCode, Vec<FileId>), Error> {
let baseline = if let Some(baseline_path_cow) = self.baseline_path.as_ref() {
let baseline_path = baseline_path_cow.as_ref();
if self.generate_baseline {
let read_database = database.read_only();
self.generate_baseline(baseline_path, &read_database, issues)?;
return Ok((ExitCode::SUCCESS, Vec::new()));
}
if self.verify_baseline {
let read_database = database.read_only();
let success = self.verify_baseline(baseline_path, &read_database, issues)?;
return Ok((if success { ExitCode::SUCCESS } else { ExitCode::FAILURE }, Vec::new()));
}
self.get_baseline(Some(baseline_path))
} else {
if !self.validate_baseline_parameters() {
return Ok((ExitCode::FAILURE, Vec::new()));
}
None
};
self.issue_processor.process_issues(orchestrator, database, issues, baseline, self.fail_on_out_of_sync_baseline)
}
fn get_baseline(&self, baseline_path: Option<&Path>) -> Option<Baseline> {
let path = baseline_path?;
if !path.exists() {
tracing::warn!("Baseline file `{}` does not exist.", path.display());
return None;
}
match unserialize_baseline(path) {
Ok((baseline, needs_warning)) => {
if needs_warning {
tracing::warn!(
"Baseline file does not specify a variant, assuming 'strict'. \
Regenerate the baseline with `--generate-baseline` to update the format."
);
}
Some(baseline)
}
Err(err) => {
tracing::error!("Failed to read baseline file at `{}`: {}", path.display(), err);
None
}
}
}
fn generate_baseline(
&self,
baseline_path: &Path,
read_database: &ReadDatabase,
issues: IssueCollection,
) -> Result<(), Error> {
tracing::info!("Generating {:?} baseline file...", self.baseline_variant);
let baseline = Baseline::generate_from_issues(&issues, read_database, self.baseline_variant);
baseline::serialize_baseline(baseline_path, &baseline, self.backup_baseline)?;
tracing::info!("Baseline file successfully generated at `{}`.", baseline_path.display());
Ok(())
}
fn verify_baseline(
&self,
baseline_path: &Path,
read_database: &ReadDatabase,
issues: IssueCollection,
) -> Result<bool, Error> {
if !baseline_path.exists() {
tracing::info!("Baseline file `{}` does not exist.", baseline_path.display());
return Ok(false);
}
tracing::info!("Verifying baseline file at `{}`...", baseline_path.display());
let (baseline, needs_warning) = unserialize_baseline(baseline_path)?;
if needs_warning {
tracing::warn!(
"Baseline file does not specify a variant, assuming 'strict'. \
Regenerate the baseline with `--generate-baseline` to update the format."
);
}
let comparison = baseline.compare_with_issues(&issues, read_database);
if comparison.is_up_to_date {
tracing::info!("Baseline is up to date.");
Ok(true)
} else {
if comparison.new_issues_count > 0 {
tracing::warn!("Found {} new issues not in the baseline.", comparison.new_issues_count);
}
if comparison.removed_issues_count > 0 {
tracing::warn!(
"Found {} issues in the baseline that no longer exist.",
comparison.removed_issues_count
);
}
tracing::error!("Baseline is outdated. {} files have changes.", comparison.files_with_changes_count);
tracing::error!("Run with `--generate-baseline` to update the baseline file.");
Ok(false)
}
}
fn validate_baseline_parameters(&self) -> bool {
if self.generate_baseline {
tracing::warn!("Cannot generate baseline file because no baseline path was specified.");
tracing::warn!("Use the `--baseline <PATH>` option to specify where to save the baseline file.");
tracing::warn!("Or set a default baseline path in the configuration file.");
false
} else if self.verify_baseline {
tracing::warn!("Cannot verify baseline file because no baseline path was specified.");
tracing::warn!("Use the `--baseline <PATH>` option to specify the baseline file to verify.");
tracing::warn!("Or set a default baseline path in the configuration file.");
false
} else if self.fail_on_out_of_sync_baseline {
tracing::warn!("Cannot fail on out-of-sync baseline because no baseline path was specified.");
tracing::warn!("Use the `--baseline <PATH>` option to specify the baseline file.");
tracing::warn!("Or set a default baseline path in the configuration file.");
true
} else {
true
}
}
}
fn check_php_code(arena: &Bump, file_id: FileId, code: &str, parser_settings: ParserSettings) -> bool {
!parse_file_content_with_settings(arena, file_id, code, parser_settings).has_errors()
}