ecformat 0.2.0

command line tool to keep files correct in respect of your EditorConfig
Documentation
// SPDX-FileCopyrightText: Contributors to ecformat project <https://codeberg.org/BaumiCoder/ecformat>
//
// SPDX-License-Identifier: BlueOak-1.0.0

//! Structs for the Command Line Interface commands and options,
//! which are also necessary as parameters for using ecformat as library.

use std::{
    env,
    path::{Path, PathBuf},
    slice, vec,
};

use anyhow::{self, Context};
use clap::{Args, Parser, Subcommand, ValueEnum};
use itertools::Itertools;

/// Supported version of the EditorConfig specification,
/// which you can use for example with
/// [`semver::Version`](https://docs.rs/semver/latest/semver/struct.Version.html) like this:
/// ```
/// use ecformat::editorconfig_version;
/// use semver::Version;
///
/// let version = Version::parse(editorconfig_version!()).expect("Parsing works");
/// assert_eq!(format!("{version}"), editorconfig_version!());
/// ```
#[macro_export]
macro_rules! editorconfig_version {
    () => {
        "0.17.2"
    };
}

/// Parser for the CLI with clap.
#[derive(Parser)]
#[command(
    version = concat!(
        clap::crate_version!(),
        " - EditorConfig Specification Version ",
        editorconfig_version!()
    ),
    about,
    long_about = None
)]
#[command(propagate_version = true, max_term_width = 100)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,
}

/// All commands of ecformat including some which are only available in the bin crate.
#[derive(Subcommand)]
pub enum Commands {
    #[command(flatten)]
    Lib(LibCommands),
    /// Show license information of ecformat and its dependencies.
    License(LicenseArgs),
}

/// Commands which are also available when using ecformat as a lib crate.
#[derive(Subcommand)]
pub enum LibCommands {
    /// Checks that the EditorConfig properties are respected in the files.
    /// If a property is violated in a file, the checks for this file are stopped,
    /// as some violations (e.g., incorrect charset), may lead to subsequence errors.
    /// However, the checks on the other files continue.
    Check(CommandArgs),
    /// Fixes violations against the EditorConfig properties in the files.
    Fix(CommandArgs),
    /// Provide status information about the usage of all enabled EditorConfig properties.
    /// By default, it only prints summary information about the number of files,
    /// for example which are using a specific EditorConfig property.
    /// Details for all files are given with increased verbosity,
    /// use for example `--verbose` to do so.
    Status(CommandArgs),
}

impl LibCommands {
    /// Returns reference to the [`CommandArgs`] inside the [`LibCommands`] instance.
    pub fn get_args(&self) -> &CommandArgs {
        match self {
            Self::Check(args) => args,
            Self::Fix(args) => args,
            Self::Status(args) => args,
        }
    }
}

/// CLI arguments for both lib commands
#[derive(Args)]
#[command(next_line_help = true)]
pub struct CommandArgs {
    /// The target directories or files to process on. Default is the current working directory.
    targets: Option<Vec<PathBuf>>,
    #[command(flatten)]
    verbosity: clap_verbosity_flag::Verbosity,
    #[command(flatten)]
    pub editorconfig_args: EditorConfigArgs,
    #[command(flatten)]
    pub ignore_args: IgnoreArgs,
}

/// Arguments specific for EditorConfig properties
#[derive(Args)]
#[command(next_help_heading = "Disable EditorConfig properties")]
pub struct EditorConfigArgs {
    /// Consider the `charset` property
    #[arg(short = 'c', long = "disable-charset", action = clap::ArgAction::SetFalse, help =
    "Disables that the charset property of EditorConfig is considered for any file. \
    Determining the actual charset of files can be faulty. Use this option for such cases.")]
    pub charset: bool,
    #[arg(short = 'e', long = "disable-end-of-line", action = clap::ArgAction::SetFalse, help =
    "Disables that the end_of_line property of EditorConfig is considered for any file.")]
    pub end_of_line: bool,
    #[arg(short = 't', long = "disable-trim-trailing-whitespace", action = clap::ArgAction::SetFalse, help =
    "Disables that the trim_trailing_whitespace property of EditorConfig is considered for any file.")]
    pub trim_trailing_whitespace: bool,
    #[arg(short = 'n', long = "disable-insert-final-newline", action = clap::ArgAction::SetFalse, help =
    "Disables that the insert_final_newline property of EditorConfig is considered for any file.")]
    pub insert_final_newline: bool,
    #[arg(short = 'i', long = "disable-indentation-handling", action = clap::ArgAction::SetFalse, help =
    "Disables that the indentation properties of EditorConfig \
    (i.e., indent_style, indent_size, tab_width) are considered for any file.")]
    pub indentation_handling: bool,
    #[arg(short = 's', long = "disable-spelling-language", action = clap::ArgAction::SetFalse, help =
    "Disables that the spelling_language property of EditorConfig is considered in any .editorconfig file.")]
    pub spelling_language: bool,
}

/// Arguments for ignoring files
#[derive(Args)]
#[command(next_help_heading = "Control which files are ignored")]
pub struct IgnoreArgs {
    /// Include hidden files and directories (but not .git directory)
    #[arg(short = 'd', long = "ignore-hidden", action = clap::ArgAction::SetFalse, help =
    "Ignore hidden files and directories (starting with a dot). Default is not to ignore them, \
    except for the .git directory with the meta data of a git repository.")]
    pub hidden: bool,
    /// Respect git settings about ignored files.
    #[arg(short = 'g', long = "no-git-settings", action = clap::ArgAction::SetFalse, help =
    "Ignore all git settings about ignored files. Default is that in a git repository \
    (i.e., root contains .git directory) the git settings are respected \
    (i.e., .gitignore and .git/info/exclude files, the git config option core.excludesFile \
    and ignoring the .git directory even if hidden directories are not ignored).")]
    pub git_settings: bool,
    /// One file name of additional ignore files (e.g., `.ecignore` or `.ignore`).
    #[arg(short = 'f', long)]
    pub ignore_file: Option<PathBuf>,
}

impl CommandArgs {
    /// Creates a new `CommandArgs` for using ecformat as lib crate
    /// with a single optional target (default is the current working directory).
    /// See the command helps in the bin crate (e.g. `ecformat help check`) for details.
    ///
    /// No support for the verbosity (`-v` / `-q`) here, as this is only used in the bin crate,
    /// and you can use your own logger (for the [log crate](https://crates.io/crates/log))
    /// when using ecformat as a library.
    pub fn new<P: AsRef<Path>>(
        target: Option<P>,
        editorconfig_args: EditorConfigArgs,
        ignore_args: IgnoreArgs,
    ) -> CommandArgs {
        let targets = target.as_ref().map(|p| slice::from_ref(p));
        CommandArgs::new_internal(targets, editorconfig_args, ignore_args)
    }

    /// Creates a new `CommandArgs` for using ecformat as lib crate
    /// with multiple targets, if the given slice of paths is not empty.
    ///
    /// For more details see the [`CommandArgs::new`] function which is for a single target.
    pub fn new_multi_target<P: AsRef<Path>>(
        targets: &[P],
        editorconfig_args: EditorConfigArgs,
        ignore_args: IgnoreArgs,
    ) -> Option<CommandArgs> {
        if targets.is_empty() {
            None
        } else {
            Some(CommandArgs::new_internal(
                Some(targets),
                editorconfig_args,
                ignore_args,
            ))
        }
    }

    fn new_internal<P: AsRef<Path>>(
        targets: Option<&[P]>,
        editorconfig_args: EditorConfigArgs,
        ignore_args: IgnoreArgs,
    ) -> CommandArgs {
        CommandArgs {
            targets: targets.map(|s| s.iter().map(|p| p.as_ref().to_path_buf()).collect_vec()),
            // Verbosity is only needed in the `main.rs` (bin crate)
            // and therefore the value is not relevant for lib usage.
            verbosity: clap_verbosity_flag::Verbosity::new(0, 0),
            editorconfig_args,
            ignore_args,
        }
    }

    pub(crate) fn targets(&self) -> anyhow::Result<Vec<PathBuf>> {
        match self.targets.as_ref() {
            Some(t) => Ok(t.iter().map(|p| p.to_path_buf()).collect_vec()),
            None => env::current_dir()
                .with_context(|| "Current working directory no accessible!")
                .map(|p| vec![p]),
        }
    }

    pub fn verbosity(&self) -> clap_verbosity_flag::Verbosity {
        self.verbosity
    }
}

/// Arguments for the license command
#[derive(Args)]
#[command(next_line_help = true)]
pub struct LicenseArgs {
    /// Output format of the license information
    #[arg(short, long, default_value_t = MarkdownFormat::Auto, value_enum)]
    pub format: MarkdownFormat,
}

/// Formats for markdown output
#[derive(Clone, ValueEnum)]
pub enum MarkdownFormat {
    /// If stdout is a terminal, paginate is used and otherwise plain
    Auto,
    /// Shows the rendered Markdown code in a pagination view
    Paginate,
    /// Prints the rendered Markdown code without pagination
    Pretty,
    /// Prints the Markdown code in plain text
    Plain,
}