mago 1.46.0

A comprehensive suite of PHP tooling inspired by Rust’s approach, providing parsing, linting, formatting, and more through a unified CLI and library interface.
//! Code formatting command implementation.
//!
//! This module implements the `mago format` (aliased as `mago fmt`) command, which
//! automatically formats PHP code according to configured style rules. The formatter
//! ensures consistent code style across the entire codebase.
//!
//! # Formatting Modes
//!
//! The formatter supports three distinct modes:
//!
//! - **In-place Formatting** (default): Modifies files directly on disk
//! - **Check Mode** (`--check`): Validates formatting without making changes (CI-friendly)
//! - **Dry Run** (`--dry-run`): Shows what would change via diff without modifying files
//! - **STDIN Mode** (`--stdin-input`): Reads from stdin, writes formatted code to stdout
//!
//! # Configuration
//!
//! Formatting style is configured in `mago.toml` under the `[formatter]` section:
//!
//! - **print-width**: Maximum line length
//! - **tab-width**: Number of spaces per indentation level
//! - **use-tabs**: Whether to use tabs instead of spaces
//! - Additional style preferences for braces, spacing, etc.
//!
//! # Output and Reporting
//!
//! - **In-place**: Prints summary of formatted files
//! - **Check**: Exits with failure code if files need formatting
//! - **Dry run**: Displays colorized diffs of proposed changes
//! - **STDIN**: Outputs formatted code directly
//!
//! # Use Cases
//!
//! - Pre-commit hooks to ensure consistent formatting
//! - CI checks to validate code style compliance
//! - Editor integration for format-on-save
//! - Bulk formatting after style guide changes

use std::borrow::Cow;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitCode;

use clap::ColorChoice;
use clap::Parser;

use mago_database::Database;
use mago_database::DatabaseReader;
use mago_database::change::ChangeLog;
use mago_database::error::DatabaseError;
use mago_database::file::File;
use mago_orchestrator::service::format::FileFormatStatus;
use mago_orchestrator::service::format::FormatResult;

use crate::EXIT_CODE_ERROR;
use crate::config::Configuration;
use crate::error::Error;
use crate::utils;
use crate::utils::create_orchestrator;
use crate::utils::git;
use crate::utils::git::get_staged_file;
use crate::utils::git::update_staged_file;

/// Command for formatting PHP source files according to style rules.
///
/// This command applies consistent formatting to PHP code based on the configured
/// style preferences. It supports multiple modes including in-place formatting,
/// check mode for CI, and dry-run mode for previewing changes.
#[derive(Parser, Debug)]
#[command(
    name = "format",
    aliases = ["fmt"],
    about = "Format source files to match defined style rules",
    long_about = r#"
The `format` command applies consistent formatting to source files based on the rules defined in the configuration file.

This command helps maintain a consistent codebase style, improving readability and collaboration.
"#
)]
pub struct FormatCommand {
    /// Format specific files or directories, overriding the source configuration.
    #[arg()]
    pub path: Vec<PathBuf>,

    /// Perform a dry run, printing a diff without modifying files.
    ///
    /// This will calculate and print a diff of any changes that would be made.
    /// No files will be modified on disk.
    #[arg(long, short = 'd', conflicts_with_all = ["check", "stdin_input"], alias = "diff")]
    pub dry_run: bool,

    /// Check if the source files are formatted.
    ///
    /// This flag is ideal for CI environments. The command will exit with a
    /// success code (`0`) if all files are formatted, and a failure code (`1`)
    /// if any files would be changed. No output is printed to `stdout`.
    #[arg(long, short = 'c', conflicts_with_all = ["dry_run", "stdin_input"])]
    pub check: bool,

    /// Read input from STDIN, format it, and write to STDOUT.
    ///
    /// This flag allows you to pipe PHP code directly into the formatter.
    ///
    /// When using this option, the formatter reads from standard input,
    /// formats the code according to the configuration, and outputs the
    /// formatted code to standard output. This is useful for integrating
    /// with other tools or for quick formatting tasks without modifying files.
    #[arg(long, short = 'i', conflicts_with_all = ["dry_run", "check", "path", "staged"])]
    pub stdin_input: bool,

    /// Logical filepath of the buffer being read from STDIN.
    ///
    /// When provided alongside `--stdin-input`, the formatter treats the
    /// piped content as if it lived at this path. The path is used to:
    ///
    /// - Honor `source.excludes` and `formatter.excludes` from `mago.toml`.
    ///   If the path matches an exclude pattern, the input is written back
    ///   to STDOUT unchanged.
    /// - Improve diagnostic messages by referencing the real filepath.
    ///
    /// Editor integrations should pass the buffer path here (for example,
    /// Zed's `{buffer_path}` substitution).
    #[arg(long, requires = "stdin_input", value_name = "PATH")]
    pub stdin_filepath: Option<PathBuf>,

    /// Format files that are staged in git.
    ///
    /// This flag is designed for git pre-commit hooks. It will:
    /// 1. Find all PHP files currently staged for commit
    /// 2. Format those staged files in memory
    /// 3. Update the staged files so the formatted version is committed
    ///
    /// Fails if:
    /// - Not in a git repository
    #[arg(long, short = 's', conflicts_with_all = ["dry_run", "check", "stdin_input", "path"])]
    pub staged: bool,
}

impl FormatCommand {
    /// Executes the formatting command.
    ///
    /// This method handles all formatting modes (in-place, check, dry-run, stdin, staged)
    /// and orchestrates the complete formatting workflow:
    ///
    /// 1. **Mode Selection**: Determines which mode to use based on flags
    /// 2. **STDIN Handling**: If `--stdin-input`, reads from stdin and formats immediately
    /// 3. **Staged Handling**: If `--staged`, formats only git-staged files
    /// 4. **Database Loading**: Scans workspace for PHP files (unless stdin mode)
    /// 5. **Service Creation**: Creates formatting service with configuration
    /// 6. **Formatting**: Processes each file according to the selected mode
    /// 7. **Reporting**: Outputs results (summary, diffs, or exit code)
    ///
    /// # Arguments
    ///
    /// * `configuration` - The configuration containing formatter settings
    /// * `color_choice` - Whether to use colored output for diffs
    ///
    /// # Returns
    ///
    /// - `Ok(ExitCode::SUCCESS)` if formatting succeeded or no changes needed
    /// - `Ok(ExitCode::FAILURE)` in check mode if files need formatting
    /// - `Err(Error)` if database loading or formatting failed
    ///
    /// # Modes
    ///
    /// - **In-place** (default): Writes formatted code back to files
    /// - **Check** (`--check`): Returns failure if any file needs formatting
    /// - **Dry run** (`--dry-run`): Prints diffs without modifying files
    /// - **STDIN** (`--stdin-input`): Formats input from stdin to stdout
    /// - **Staged** (`--staged`): Formats staged files and re-stages them
    pub fn execute(self, configuration: Configuration, color_choice: ColorChoice) -> Result<ExitCode, Error> {
        if self.staged {
            return self.execute_staged(configuration, color_choice);
        }

        let mut orchestrator = create_orchestrator(&configuration, color_choice, false, true, false);
        orchestrator.add_exclude_patterns(configuration.formatter.excludes.iter());
        if !self.path.is_empty() {
            orchestrator.set_source_paths(self.path.iter().map(|p| p.to_string_lossy().to_string()));
        }

        if self.stdin_input {
            return self.execute_stdin(orchestrator, &configuration);
        }

        let mut database = orchestrator.load_database(&configuration.source.workspace, false, None, None)?;
        let service = orchestrator.get_format_service(database.read_only());

        let result = service.run()?;

        for (file_id, parse_error) in result.parse_errors() {
            let file = database.get_ref(file_id)?;

            tracing::error!("Failed to parse file '{}': {parse_error}", mago_bytes::BytesDisplay(&file.name));
        }

        let changed_files_count = result.changed_files_count();

        if changed_files_count == 0 {
            tracing::info!("All files are already formatted.");

            return Ok(ExitCode::SUCCESS);
        }

        if self.check {
            tracing::info!(
                "Found {changed_files_count} file(s) need formatting. Run the command without '--check' to format them.",
            );

            return Ok(ExitCode::FAILURE);
        }

        let change_log = to_change_log(&database, &result, self.dry_run, color_choice)?;
        database.commit(change_log, true)?;

        let exit_code = if self.dry_run {
            tracing::info!("Found {changed_files_count} file(s) that need formatting.");

            ExitCode::FAILURE
        } else {
            tracing::info!("Formatted {changed_files_count} file(s) successfully.");

            ExitCode::SUCCESS
        };

        Ok(exit_code)
    }

    /// Executes the STDIN formatting flow.
    ///
    /// When `--stdin-filepath` is set, the buffer is registered with the database
    /// loader using its real workspace-relative name. The loader honors `source`
    /// and `formatter` exclude rules: if the path matches an exclude pattern,
    /// the file isn't loaded and we pass the original input through unchanged.
    ///
    /// Without `--stdin-filepath`, the buffer is treated as an ad-hoc snippet
    /// and formatted unconditionally.
    fn execute_stdin(
        self,
        mut orchestrator: mago_orchestrator::Orchestrator<'_>,
        configuration: &Configuration,
    ) -> Result<ExitCode, Error> {
        // PHP source is binary-safe, so read raw bytes: a buffer piped in may not be valid UTF-8.
        let mut content: Vec<u8> = Vec::new();
        std::io::stdin().read_to_end(&mut content).map_err(|e| Error::Database(DatabaseError::IOError(e)))?;

        let Some(filepath) = self.stdin_filepath.as_deref() else {
            let file = File::ephemeral(Cow::Borrowed(b"<stdin>"), Cow::Owned(content));
            return Ok(emit_stdin_result(orchestrator.format_file(&file)?, &file));
        };

        let logical_name = stdin_logical_name(filepath, &configuration.source.workspace);
        orchestrator.set_source_paths([filepath.to_string_lossy().to_string()]);

        let database = orchestrator.load_database(
            &configuration.source.workspace,
            false,
            None,
            Some((logical_name.clone(), content.clone())),
        )?;

        let file = match database.get_by_name(logical_name.as_bytes()) {
            Ok(file) => file.clone(),
            Err(_) => {
                // File is excluded.
                use std::io::Write;
                let _ = std::io::stdout().write_all(&content);
                return Ok(ExitCode::SUCCESS);
            }
        };

        let status = orchestrator.get_format_service(database.read_only()).format_file(&file)?;

        Ok(emit_stdin_result(status, &file))
    }

    /// Executes formatting for staged files.
    ///
    /// This method implements the `--staged` mode for git pre-commit hooks:
    ///
    /// 1. Verifies we're in a git repository
    /// 2. Gets the list of staged PHP files
    /// 3. Formats the staged files in memory
    /// 4. Updates the staged file with its formatted version
    ///
    /// # Arguments
    ///
    /// * `configuration` - The configuration containing formatter settings
    /// * `color_choice` - Whether to use colored output
    ///
    /// # Returns
    ///
    /// - `Ok(ExitCode::SUCCESS)` if formatting succeeded
    /// - `Err(Error::NotAGitRepository)` if not in a git repository
    fn execute_staged(self, configuration: Configuration, color_choice: ColorChoice) -> Result<ExitCode, Error> {
        let workspace = &configuration.source.workspace;

        let mut orchestrator = create_orchestrator(&configuration, color_choice, false, true, false);
        orchestrator.add_exclude_patterns(configuration.formatter.excludes.iter());

        let database = orchestrator.load_database(workspace, false, None, None)?;

        // Get staged files resolved to file IDs
        let staged_file_paths = git::get_staged_file_paths(workspace)?;
        if staged_file_paths.is_empty() {
            tracing::info!("No staged files to format.");
            return Ok(ExitCode::SUCCESS);
        }

        let mut changed_files_count = 0;
        for path in staged_file_paths {
            let absolute_path = workspace.join(&path);
            let canonical_path = absolute_path.canonicalize().unwrap_or(absolute_path);

            if database.get_by_path(&canonical_path).is_err() {
                continue;
            }
            let staged_file = get_staged_file(workspace, &path)?;
            match orchestrator.format_file(&staged_file)? {
                FileFormatStatus::Unchanged => continue,
                FileFormatStatus::Changed(new_content) => {
                    update_staged_file(workspace, &path, new_content)?;
                    changed_files_count += 1;
                }
                FileFormatStatus::FailedToParse(parse_error) => {
                    tracing::error!("Failed to parse staged file '{}': {}", path.display(), parse_error);
                }
            };
        }

        if changed_files_count == 0 {
            tracing::info!("All staged files are already formatted.");
            return Ok(ExitCode::SUCCESS);
        }

        tracing::info!("Formatted and re-staged {changed_files_count} file(s).");

        Ok(ExitCode::SUCCESS)
    }
}

fn to_change_log(
    database: &Database<'_>,
    format_result: &FormatResult,
    dry_run: bool,
    color_choice: ColorChoice,
) -> Result<ChangeLog, Error> {
    let change_log = ChangeLog::new();
    for (file_id, new_content) in format_result.changed_files() {
        let file = database.get_ref(file_id)?;
        utils::apply_update(&change_log, file, new_content, dry_run, color_choice)?;
    }

    Ok(change_log)
}

fn emit_stdin_result(status: FileFormatStatus, file: &File) -> ExitCode {
    use std::io::Write;
    match status {
        FileFormatStatus::Unchanged => {
            let _ = std::io::stdout().write_all(&file.contents);
            ExitCode::SUCCESS
        }
        FileFormatStatus::Changed(new_content) => {
            let _ = std::io::stdout().write_all(&new_content);
            ExitCode::SUCCESS
        }
        FileFormatStatus::FailedToParse(parse_error) => {
            tracing::error!("Failed to parse {}: {parse_error}", mago_bytes::BytesDisplay(&file.name));
            ExitCode::from(EXIT_CODE_ERROR)
        }
    }
}

/// Computes a workspace-relative, forward-slash logical name for `filepath`.
///
/// Falls back to the path as given when it isn't a descendant of the workspace.
fn stdin_logical_name(filepath: &Path, workspace: &Path) -> String {
    let canonical_filepath = filepath.canonicalize();
    let canonical_workspace = workspace.canonicalize();

    let stripped: &Path = match (canonical_filepath.as_ref(), canonical_workspace.as_ref()) {
        (Ok(fp), Ok(ws)) => fp.strip_prefix(ws).unwrap_or(fp),
        _ => filepath.strip_prefix(workspace).unwrap_or(filepath),
    };

    #[cfg(windows)]
    let mut name = stripped.to_string_lossy().replace('\\', "/");
    #[cfg(not(windows))]
    let mut name = stripped.to_string_lossy().into_owned();

    while let Some(rest) = name.strip_prefix("./") {
        name = rest.to_string();
    }

    name
}