use std::io::{self, IsTerminal};
use std::path::PathBuf;
use std::process::ExitCode;
use std::str::FromStr;
mod cli;
use cli::commands::{
install_git_hook, render_man_page, run_config_subcommand, run_dump_subcommand,
run_list_unknown_commands, run_watch,
};
use cli::errors::render_cli_error;
use cli::process::{collect_targets, compile_file_filter, process_targets, ProgressReporter};
use cli::report::{machine_mode_exit_code, print_non_human_report};
use cli::runtime::{
check_required_version, debug_parallel_suffix, handle_completed_target, is_stdout_mode,
log_debug, progress_bar_suppressed_reason, resolve_parallel_jobs, should_enable_progress_bar,
should_print_human_summary, validate_cli, write_diff_to_stdout, write_in_place_updates,
HumanOutputState, RunState,
};
use cli::summary::{render_human_summary, render_stat_summary};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{generate, Shell};
use cmakefmt::{CaseStyle, DumpConfigFormat};
use serde::Serialize;
const LONG_ABOUT: &str = "
Parse CMake listfiles and format them nicely.
Formatting is configurable with one or more YAML or TOML configuration files.
If no config file is specified on the command line, cmakefmt will try to find
the nearest .cmakefmt.yaml, .cmakefmt.yml, or .cmakefmt.toml for each input by
walking up through parent directories to the repository root or filesystem
root. If no project-local config exists, cmakefmt falls back to the same files
in the home directory when present.
Direct file arguments are always processed, even if ignore files would skip
them during recursive discovery. Ignore rules only affect files discovered
from directories, --files-from, or Git-aware selection modes.
Use `cmakefmt config init` to generate a starter .cmakefmt.yaml, or
`cmakefmt config dump` to print the full default template.
Legacy cmake-format config files can be converted with
`cmakefmt config convert <path>`.
Use `cmakefmt config path` to inspect which config file was selected,
`cmakefmt config show` for the effective config, and `cmakefmt config explain`
for a human-readable explanation of config resolution.";
fn cli_styles() -> clap::builder::Styles {
use clap::builder::styling::{AnsiColor, Effects, Style};
clap::builder::Styles::styled()
.header(
Style::new()
.fg_color(Some(AnsiColor::Green.into()))
.effects(Effects::BOLD),
)
.usage(
Style::new()
.fg_color(Some(AnsiColor::Green.into()))
.effects(Effects::BOLD),
)
.literal(Style::new().fg_color(Some(AnsiColor::Cyan.into())))
.placeholder(Style::new().fg_color(Some(AnsiColor::Cyan.into())))
.valid(Style::new().fg_color(Some(AnsiColor::Green.into())))
.invalid(
Style::new()
.fg_color(Some(AnsiColor::Red.into()))
.effects(Effects::BOLD),
)
.error(
Style::new()
.fg_color(Some(AnsiColor::Red.into()))
.effects(Effects::BOLD),
)
}
#[derive(Parser, Debug)]
#[command(
name = "cmakefmt",
version,
long_version = env!("CMAKEFMT_CLI_LONG_VERSION"),
about = "Parse CMake listfiles and format them nicely.",
long_about = LONG_ABOUT,
styles = cli_styles(),
)]
struct Cli {
#[command(flatten)]
input_selection: InputSelectionArgs,
#[command(flatten)]
output_modes: OutputModesArgs,
#[command(flatten)]
execution: ExecutionArgs,
#[command(flatten)]
config_overrides: ConfigOverridesArgs,
#[command(subcommand)]
command: Option<CliCommand>,
}
#[derive(Args, Debug, Clone)]
struct InputSelectionArgs {
files: Vec<String>,
#[arg(
long = "files-from",
value_name = "PATH",
help_heading = "Input Selection"
)]
files_from: Vec<String>,
#[arg(
long = "path-regex",
value_name = "REGEX",
help_heading = "Input Selection"
)]
file_regex: Option<String>,
#[arg(
long = "ignore-path",
value_name = "PATH",
help_heading = "Input Selection"
)]
ignore_paths: Vec<PathBuf>,
#[arg(long = "no-gitignore", help_heading = "Input Selection")]
no_gitignore: bool,
#[arg(long, help_heading = "Input Selection")]
sorted: bool,
#[arg(long, help_heading = "Input Selection", conflicts_with = "staged")]
changed: bool,
#[arg(long, help_heading = "Input Selection", conflicts_with = "changed")]
staged: bool,
#[arg(
long,
requires = "changed",
value_name = "REF",
help_heading = "Input Selection"
)]
since: Option<String>,
#[arg(
long = "stdin-path",
value_name = "PATH",
help_heading = "Input Selection"
)]
stdin_path: Option<PathBuf>,
#[arg(
long = "lines",
value_name = "START:END",
help_heading = "Input Selection"
)]
line_ranges: Vec<LineRange>,
}
#[derive(Args, Debug, Clone)]
struct OutputModesArgs {
#[arg(
short = 'i',
long = "in-place",
help_heading = "Output Modes",
conflicts_with = "list_changed_files",
conflicts_with = "list_input_files"
)]
in_place: bool,
#[arg(
long,
help_heading = "Output Modes",
conflicts_with = "list_input_files"
)]
check: bool,
#[arg(
long = "list-changed-files",
alias = "list-files",
help_heading = "Output Modes",
conflicts_with = "quiet",
conflicts_with = "list_input_files"
)]
list_changed_files: bool,
#[arg(
long = "list-input-files",
help_heading = "Output Modes",
conflicts_with = "check",
conflicts_with = "list_changed_files",
conflicts_with = "in_place",
conflicts_with = "diff",
conflicts_with = "quiet"
)]
list_input_files: bool,
#[arg(
long = "list-unknown-commands",
help_heading = "Output Modes",
conflicts_with = "check",
conflicts_with = "in_place",
conflicts_with = "diff",
conflicts_with = "list_changed_files",
conflicts_with = "list_input_files",
conflicts_with = "explain",
conflicts_with = "watch",
conflicts_with = "quiet",
conflicts_with = "progress_bar"
)]
list_unknown_commands: bool,
#[arg(short, long, help_heading = "Output Modes", conflicts_with = "quiet")]
summary: bool,
#[arg(
short,
long,
help_heading = "Output Modes",
conflicts_with = "in_place"
)]
diff: bool,
#[arg(
long,
help_heading = "Output Modes",
conflicts_with = "check",
conflicts_with = "in_place",
conflicts_with = "diff",
conflicts_with = "list_changed_files",
conflicts_with = "list_input_files",
conflicts_with = "quiet",
conflicts_with = "progress_bar"
)]
explain: bool,
#[arg(
long = "report-format",
value_enum,
default_value_t = ReportFormat::Human,
help_heading = "Output Modes"
)]
report_format: ReportFormat,
#[arg(
long = "color",
alias = "colour",
value_enum,
default_value_t = ColorChoice::Auto,
help_heading = "Output Modes"
)]
color: ColorChoice,
}
#[derive(Args, Debug, Clone)]
struct ExecutionArgs {
#[arg(long = "generate-man-page", hide = true)]
generate_man_page: bool,
#[arg(long, help_heading = "Execution")]
debug: bool,
#[arg(short, long, help_heading = "Execution")]
quiet: bool,
#[arg(long, help_heading = "Execution")]
stat: bool,
#[arg(long = "keep-going", help_heading = "Execution")]
keep_going: bool,
#[arg(
long,
help_heading = "Execution",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "list_changed_files",
conflicts_with = "list_input_files",
conflicts_with = "quiet",
conflicts_with = "explain",
conflicts_with = "progress_bar"
)]
watch: bool,
#[arg(long, help_heading = "Execution")]
cache: bool,
#[arg(
long = "cache-location",
value_name = "PATH",
help_heading = "Execution"
)]
cache_location: Option<PathBuf>,
#[arg(
long = "cache-strategy",
value_enum,
default_value_t = CacheStrategy::Metadata,
help_heading = "Execution"
)]
cache_strategy: CacheStrategy,
#[arg(
short = 'j',
long,
value_name = "JOBS",
help_heading = "Execution",
num_args = 0..=1,
default_missing_value = "0",
)]
parallel: Option<usize>,
#[arg(short, long = "progress-bar", help_heading = "Execution")]
progress_bar: bool,
#[arg(long, value_name = "VERSION", help_heading = "Execution")]
required_version: Option<String>,
#[arg(long, help_heading = "Execution", conflicts_with = "no_verify")]
verify: bool,
#[arg(
long = "no-verify",
alias = "fast",
help_heading = "Execution",
conflicts_with = "verify"
)]
no_verify: bool,
#[arg(long, help_heading = "Execution")]
require_pragma: bool,
}
#[derive(Args, Debug, Clone)]
struct ConfigOverridesArgs {
#[arg(
short = 'c',
long = "config-file",
visible_alias = "config",
value_name = "PATH",
help_heading = "Config Overrides"
)]
config_paths: Vec<PathBuf>,
#[arg(long, help_heading = "Config Overrides")]
no_config: bool,
#[arg(long = "no-editorconfig", help_heading = "Config Overrides")]
no_editorconfig: bool,
#[arg(short = 'l', long, help_heading = "Config Overrides")]
line_width: Option<usize>,
#[arg(long, help_heading = "Config Overrides")]
tab_size: Option<usize>,
#[arg(long, help_heading = "Config Overrides")]
command_case: Option<CaseStyle>,
#[arg(long, help_heading = "Config Overrides")]
keyword_case: Option<CaseStyle>,
#[arg(long, help_heading = "Config Overrides")]
dangle_parens: Option<bool>,
}
#[derive(Clone, Debug, Subcommand)]
enum CliCommand {
Lsp,
Completions {
#[arg(value_enum)]
shell: Shell,
},
Manpage,
InstallHook,
Config {
#[command(subcommand)]
action: ConfigAction,
},
Dump {
#[command(subcommand)]
action: DumpAction,
#[arg(global = true)]
file: Option<PathBuf>,
},
}
#[derive(Clone, Debug, Subcommand)]
enum ConfigAction {
Dump {
#[arg(long, value_enum, default_value = "yaml")]
format: DumpConfigFormat,
},
Schema,
Check {
path: Option<String>,
},
Show {
path: Option<String>,
#[arg(long, value_enum, default_value = "yaml")]
format: DumpConfigFormat,
},
Path {
path: Option<String>,
},
Explain {
path: Option<String>,
},
Convert {
paths: Vec<PathBuf>,
#[arg(long, value_enum, default_value = "yaml")]
format: DumpConfigFormat,
},
Init,
}
#[derive(Clone, Debug, Subcommand)]
enum DumpAction {
Ast,
Parse,
SpecCoverage {
#[arg(long, value_enum, default_value = "human")]
format: SpecCoverageFormat,
#[arg(long, value_enum)]
status: Option<SpecCoverageStatusFilter>,
},
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum SpecCoverageFormat {
Human,
Json,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum SpecCoverageStatusFilter {
Missing,
Stub,
Partial,
Full,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum ColorChoice {
Auto,
Always,
Never,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum ReportFormat {
Human,
Json,
Github,
Checkstyle,
Junit,
Sarif,
Edit,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum CacheStrategy {
Metadata,
Content,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct LineRange {
start: usize,
end: usize,
}
impl LineRange {
fn contains(&self, line: usize) -> bool {
self.start <= line && line <= self.end
}
}
impl FromStr for LineRange {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let Some((start, end)) = value.split_once(':') else {
return Err("expected START:END".to_owned());
};
let start = start
.parse::<usize>()
.map_err(|_| "line range start must be a positive integer".to_owned())?;
let end = end
.parse::<usize>()
.map_err(|_| "line range end must be a positive integer".to_owned())?;
if start == 0 || end == 0 {
return Err("line ranges are 1-based".to_owned());
}
if end < start {
return Err("line range end must be >= start".to_owned());
}
Ok(Self { start, end })
}
}
const EXIT_OK: u8 = 0;
const EXIT_CHECK_FAILED: u8 = 1;
const EXIT_ERROR: u8 = 2;
fn main() -> ExitCode {
std::panic::set_hook(Box::new(|info| {
let message = if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"unknown".to_string()
};
let location = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "unknown".to_string());
eprintln!(
"\
cmakefmt encountered an internal error and crashed.
This is a bug. Please report it at:
https://github.com/cmakefmt/cmakefmt/issues/new
Include the following in your report:
cmakefmt version: {}
OS: {} ({})
panic: {}
location: {}",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
message,
location,
);
}));
let cli = Cli::parse();
match run(&cli) {
Ok(code) => ExitCode::from(code),
Err(cmakefmt::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::BrokenPipe => {
ExitCode::from(EXIT_OK)
}
Err(cmakefmt::Error::IoAt { ref source, .. })
if source.kind() == std::io::ErrorKind::BrokenPipe =>
{
ExitCode::from(EXIT_OK)
}
Err(err) => {
eprintln!("{}", render_cli_error(&err));
ExitCode::from(EXIT_ERROR)
}
}
}
fn run(cli: &Cli) -> Result<u8, cmakefmt::Error> {
check_required_version(cli)?;
match &cli.command {
#[cfg(feature = "lsp")]
Some(CliCommand::Lsp) => {
cmakefmt::lsp::run().map_err(|e| cmakefmt::Error::Formatter(e.to_string()))?;
return Ok(EXIT_OK);
}
Some(CliCommand::Completions { shell }) => {
let mut command = Cli::command();
generate(*shell, &mut command, "cmakefmt", &mut io::stdout());
return Ok(EXIT_OK);
}
Some(CliCommand::Manpage) => {
return render_man_page();
}
Some(CliCommand::InstallHook) => {
return install_git_hook();
}
Some(CliCommand::Config { action }) => {
return run_config_subcommand(cli, action);
}
Some(CliCommand::Dump { action, file }) => {
return run_dump_subcommand(cli, action, file.as_deref());
}
None => {}
}
if cli.execution.generate_man_page {
return render_man_page();
}
validate_cli(cli)?;
let stdout_mode = is_stdout_mode(cli);
let colorize_stdout = stdout_mode && should_colorize_stdout(cli.output_modes.color);
let file_filter = compile_file_filter(cli.input_selection.file_regex.as_deref())?;
let mut targets = collect_targets(cli, file_filter.as_ref())?;
if cli.input_selection.sorted {
targets.sort_by(|a, b| {
a.display_name(cli.input_selection.stdin_path.as_deref())
.cmp(&b.display_name(cli.input_selection.stdin_path.as_deref()))
});
}
if cli.output_modes.list_input_files {
for target in &targets {
println!(
"{}",
target.display_name(cli.input_selection.stdin_path.as_deref())
);
}
return Ok(EXIT_OK);
}
if cli.output_modes.list_unknown_commands {
return run_list_unknown_commands(cli, &targets);
}
if cli.output_modes.explain && targets.len() != 1 {
return Err(cmakefmt::Error::cli_arg(
"--explain requires exactly one formatting target",
));
}
if cli.execution.watch {
return run_watch(cli, &targets, file_filter.as_ref());
}
if !cli.input_selection.line_ranges.is_empty() && targets.len() != 1 {
return Err(cmakefmt::Error::cli_arg(
"--lines requires exactly one formatting target",
));
}
let parallel_jobs = resolve_parallel_jobs(cli.execution.parallel)?;
let stdout_is_terminal = io::stdout().is_terminal();
let stderr_is_terminal = io::stderr().is_terminal();
let colorize_stderr = should_colorize_stderr(cli.output_modes.color);
if let Some(reason) =
progress_bar_suppressed_reason(cli, targets.len(), stdout_is_terminal, stderr_is_terminal)
{
if colorize_stderr {
eprintln!("\n\x1b[1;93mâš warning: --progress-bar ignored ({reason})\x1b[0m\n");
} else {
eprintln!("\nwarning: --progress-bar ignored ({reason})\n");
}
}
let progress = ProgressReporter::new(
should_enable_progress_bar(cli, targets.len(), stdout_is_terminal, stderr_is_terminal),
targets.len(),
);
if cli.execution.debug {
log_debug(format!(
"discovered {} target(s){}",
targets.len(),
debug_parallel_suffix(parallel_jobs)
));
}
let start_time = std::time::Instant::now();
let mut state = RunState {
results: Vec::new(),
failures: Vec::new(),
summary: RunSummary {
selected: targets.len(),
..RunSummary::default()
},
human_output: HumanOutputState::new(stdout_mode && targets.len() > 1),
};
process_targets(
&targets,
cli,
parallel_jobs,
colorize_stdout,
&progress,
|target_result| {
handle_completed_target(
target_result,
cli,
colorize_stdout,
colorize_stderr,
&progress,
&mut state,
)
},
)?;
state.summary.elapsed = start_time.elapsed();
let RunState {
results,
failures,
summary,
..
} = state;
if cli.output_modes.in_place {
write_in_place_updates(&results)?;
}
if cli.output_modes.report_format != ReportFormat::Human {
if cli.output_modes.diff && cli.output_modes.report_format == ReportFormat::Github {
for result in &results {
if result.would_change {
write_diff_to_stdout(result, colorize_stdout)?;
}
}
}
print_non_human_report(
&cli.output_modes,
&cli.execution,
&results,
&failures,
&summary,
)?;
return machine_mode_exit_code(&results, &failures, &summary, &cli.output_modes);
}
if should_print_human_summary(cli, &summary, &failures, results.len()) {
progress.eprintln(&render_human_summary(&summary))?;
}
if cli.execution.stat {
progress.eprintln(&render_stat_summary(&summary))?;
}
if cli.output_modes.check
&& !cli.execution.quiet
&& summary.changed > 0
&& cli.output_modes.report_format == ReportFormat::Human
{
progress.eprintln("hint: run `cmakefmt --in-place .` to fix formatting")?;
}
if !failures.is_empty() {
Ok(EXIT_ERROR)
} else if (cli.output_modes.check || cli.output_modes.list_changed_files) && summary.changed > 0
{
Ok(EXIT_CHECK_FAILED)
} else {
Ok(EXIT_OK)
}
}
#[derive(Debug, Default, Serialize)]
struct RunSummary {
selected: usize,
changed: usize,
unchanged: usize,
skipped: usize,
failed: usize,
total_changed_lines: usize,
#[serde(skip)]
elapsed: std::time::Duration,
}
fn should_colorize_stdout(choice: ColorChoice) -> bool {
match choice {
ColorChoice::Auto => {
io::stdout().is_terminal()
&& std::env::var_os("NO_COLOR").is_none()
&& std::env::var("TERM").map_or(true, |term| term != "dumb")
}
ColorChoice::Always => true,
ColorChoice::Never => false,
}
}
fn should_colorize_stderr(choice: ColorChoice) -> bool {
match choice {
ColorChoice::Auto => {
io::stderr().is_terminal()
&& std::env::var_os("NO_COLOR").is_none()
&& std::env::var("TERM").map_or(true, |term| term != "dumb")
}
ColorChoice::Always => true,
ColorChoice::Never => false,
}
}
#[cfg(test)]
mod tests {
use clap::{CommandFactory, Parser};
use super::Cli;
use crate::cli::runtime::{should_enable_progress_bar, streams_stdout_during_run};
use cmakefmt::{default_config_template, default_config_template_for, DumpConfigFormat};
#[test]
fn dump_config_covers_config_backed_long_flags() {
let template = default_config_template();
let non_config_flags = [
"check",
"config-file",
"color",
"changed",
"debug",
"diff",
"explain",
"path-regex",
"files-from",
"generate-man-page",
"help",
"ignore-path",
"keep-going",
"cache",
"cache-location",
"cache-strategy",
"lines",
"list-changed-files",
"list-input-files",
"list-unknown-commands",
"no-config",
"no-editorconfig",
"no-gitignore",
"sorted",
"parallel",
"progress-bar",
"quiet",
"summary",
"stat",
"report-format",
"required-version",
"verify",
"no-verify",
"require-pragma",
"since",
"staged",
"stdin-path",
"version",
"watch",
"in-place",
];
for arg in Cli::command().get_arguments() {
let Some(long) = arg.get_long() else {
continue;
};
if non_config_flags.contains(&long) {
continue;
}
let template_key = long.replace('-', "_");
assert!(
template.contains(&template_key),
"CLI flag --{long} is not represented in default_config_template(); \
update src/config/file.rs or add --{long} to the non-config flag allowlist in src/main.rs tests"
);
}
}
#[test]
fn toml_dump_config_covers_config_backed_long_flags() {
let template = default_config_template_for(DumpConfigFormat::Toml);
for key in [
"line_width",
"tab_size",
"use_tabs",
"max_empty_lines",
"max_hanging_wrap_lines",
"max_hanging_wrap_positional_args",
"max_hanging_wrap_groups",
"dangle_parens",
"dangle_align",
"min_prefix_length",
"max_prefix_length",
"space_before_control_paren",
"space_before_definition_paren",
"command_case",
"keyword_case",
] {
assert!(
template.contains(key),
"TOML dump template is missing {key}"
);
}
}
#[test]
fn progress_bar_policy_disables_live_stdout_on_a_terminal() {
for args in [
&["cmakefmt", "--progress-bar", "CMakeLists.txt"][..],
&["cmakefmt", "--progress-bar", "--diff", "CMakeLists.txt"][..],
&[
"cmakefmt",
"--progress-bar",
"--list-changed-files",
"CMakeLists.txt",
][..],
] {
let cli = Cli::parse_from(args);
assert!(streams_stdout_during_run(&cli));
assert!(
!should_enable_progress_bar(&cli, 2, true, true),
"progress bar should be disabled for args: {:?}",
args
);
}
}
#[test]
fn progress_bar_policy_allows_non_streaming_modes_on_a_terminal() {
for args in [
&["cmakefmt", "--progress-bar", "--check", "CMakeLists.txt"][..],
&["cmakefmt", "--progress-bar", "--summary", "CMakeLists.txt"][..],
&["cmakefmt", "--progress-bar", "--quiet", "CMakeLists.txt"][..],
&["cmakefmt", "--progress-bar", "--in-place", "CMakeLists.txt"][..],
&[
"cmakefmt",
"--progress-bar",
"--report-format",
"json",
"CMakeLists.txt",
][..],
] {
let cli = Cli::parse_from(args);
assert!(
should_enable_progress_bar(&cli, 2, true, true),
"progress bar should be enabled for args: {:?}",
args
);
}
}
#[test]
fn progress_bar_policy_allows_streaming_stdout_when_stdout_is_piped() {
let cli = Cli::parse_from(["cmakefmt", "--progress-bar", "--diff", "CMakeLists.txt"]);
assert!(streams_stdout_during_run(&cli));
assert!(should_enable_progress_bar(&cli, 2, false, true));
}
#[test]
fn progress_bar_policy_requires_stderr_terminal_and_multiple_targets() {
let cli = Cli::parse_from(["cmakefmt", "--progress-bar", "--check", "CMakeLists.txt"]);
assert!(!should_enable_progress_bar(&cli, 1, true, true));
assert!(!should_enable_progress_bar(&cli, 2, true, false));
}
}