#![allow(
clippy::too_many_lines,
clippy::struct_excessive_bools,
clippy::similar_names,
clippy::needless_pass_by_value,
// `run` panics on a handful of provably-unreachable invariants
// (mutex poisoning where every worker thread has joined, channel
// sends after run_walk returns). Each one is documented at the
// call site with an `expect` reason — surfacing them in a `# Panics`
// section on the entry point adds noise without adding signal.
clippy::missing_panics_doc
)]
mod baseline;
mod check_format;
mod format_util;
mod formats;
mod html_report;
mod markdown_report;
mod metric_catalog;
mod thresholds;
use std::collections::{BTreeMap, HashMap, hash_map};
use std::ffi::OsString;
use std::fmt::Display;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::process;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::available_parallelism;
use clap::{Args, Parser, Subcommand, ValueEnum};
use globset::{Glob, GlobSet, GlobSetBuilder};
use baseline::Baseline;
use check_format::{AggregatedFormat, violation_to_offender};
use formats::{CBOR_STDOUT_ERROR, MetricsDispatch, MetricsFormat, ReportFormat, dump_csv};
use html_report::generate_html_report;
use markdown_report::{FunctionSummary, extract_summaries, generate_report};
use metric_catalog::{ListMetricsMode, write_metrics};
use thresholds::{ThresholdConfig, ThresholdSet, Violation, parse_cli_threshold};
use big_code_analysis::LANG;
use big_code_analysis::ParserTrait;
const FEATURES_PINNED: &str = "CLI pins big-code-analysis features = [\"all-languages\"]";
use big_code_analysis::{
CommentRm, CommentRmCfg, ConcurrentRunner, Count, CountCfg, Dump, DumpCfg, FilesData, Find,
FindCfg, Function, FunctionCfg, Metrics, MetricsCfg, MetricsOptions, OpsCfg, OpsCode,
PreprocParser, PreprocResults, SuppressionPolicy,
};
#[allow(deprecated)]
use big_code_analysis::get_function_spaces_with_options;
use big_code_analysis::{
action, fix_includes, get_from_ext, get_ops, guess_language, is_generated, preprocess,
read_file, read_file_with_eol, write_file,
};
fn die(msg: impl Display) -> ! {
eprintln!("Error: {msg}");
process::exit(1);
}
fn die_io(verb: &str, path: &Path, err: impl Display) -> ! {
die(format_args!("failed to {verb} {}: {err}", path.display()))
}
fn write_stdout_or_die(bytes: &[u8]) {
if let Err(e) = std::io::stdout().lock().write_all(bytes)
&& e.kind() != ErrorKind::BrokenPipe
{
die(e);
}
}
#[derive(Parser, Debug)]
#[clap(
name = "bca",
version,
author,
about = "Analyze source code.",
subcommand_required = true,
arg_required_else_help = true,
after_help = "Migrating from the flag-style CLI? See the migration guide:\n big-code-analysis-book/src/migration.md"
)]
pub struct Cli {
#[clap(flatten)]
globals: GlobalOpts,
#[command(subcommand)]
command: Command,
}
#[derive(Args, Debug, Default)]
struct GlobalOpts {
#[clap(long, short, value_parser, global = true)]
paths: Vec<PathBuf>,
#[clap(long, short = 'I', num_args(0..), global = true)]
include: Vec<String>,
#[clap(long, short = 'X', num_args(0..), global = true)]
exclude: Vec<String>,
#[clap(long, short = 'j', global = true)]
num_jobs: Option<usize>,
#[clap(long, short = 'l', global = true)]
language_type: Option<String>,
#[clap(long = "ls", global = true)]
line_start: Option<usize>,
#[clap(long = "le", global = true)]
line_end: Option<usize>,
#[clap(long, short, global = true)]
warning: bool,
#[clap(long, global = true)]
no_skip_generated: bool,
#[clap(long, global = true)]
report_skipped: bool,
#[clap(long, value_parser, global = true)]
preproc_data: Option<PathBuf>,
#[clap(long = "paths-from", value_parser, global = true)]
paths_from: Option<PathBuf>,
#[clap(long = "no-ignore", global = true)]
no_ignore: bool,
#[clap(long = "exclude-tests", global = true)]
exclude_tests: bool,
}
#[derive(Subcommand, Debug)]
enum Command {
Metrics(StructuredArgs),
Ops(StructuredArgs),
Report(ReportArgs),
Dump,
Find(NodesArgs),
Count(NodesArgs),
Functions,
StripComments(StripCommentsArgs),
Preproc(PreprocArgs),
ListMetrics(ListMetricsArgs),
Check(CheckArgs),
}
#[derive(Args, Debug)]
struct StructuredArgs {
#[clap(long, short = 'O', value_enum)]
output_format: Option<MetricsFormat>,
#[clap(long, short, value_parser)]
output: Option<PathBuf>,
#[clap(long)]
pretty: bool,
}
#[derive(Args, Debug)]
struct ReportArgs {
#[clap(value_enum)]
format: ReportFormat,
#[clap(long, short, value_parser)]
output: Option<PathBuf>,
#[clap(long, default_value_t = 20, value_parser = clap::value_parser!(u32).range(1..))]
top: u32,
#[clap(long, default_value = "")]
strip_prefix: String,
}
#[derive(Args, Debug)]
struct NodesArgs {
#[clap(required = true, num_args = 1..)]
nodes: Vec<String>,
}
#[derive(Args, Debug)]
struct StripCommentsArgs {
#[clap(long)]
in_place: bool,
}
#[derive(Args, Debug)]
struct PreprocArgs {
#[clap(long, short, value_parser)]
output: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct CheckArgs {
#[clap(long = "threshold", value_parser = parse_cli_threshold)]
thresholds: Vec<(String, f64)>,
#[clap(long, value_parser)]
config: Option<PathBuf>,
#[clap(long = "no-fail")]
no_fail: bool,
#[clap(long = "no-suppress")]
no_suppress: bool,
#[clap(long = "output-format", short = 'O', value_enum)]
output_format: Option<AggregatedFormat>,
#[clap(long, short, value_parser)]
output: Option<PathBuf>,
#[clap(long = "baseline", value_parser, conflicts_with = "write_baseline")]
baseline: Option<PathBuf>,
#[clap(
long = "write-baseline",
value_parser,
conflicts_with_all = ["baseline", "output_format", "output"],
)]
write_baseline: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct ListMetricsArgs {
#[clap(value_enum, default_value_t = ListMetricsMode::Names)]
mode: ListMetricsMode,
}
#[derive(Debug)]
enum Action {
Dump,
Metrics {
format: Option<MetricsFormat>,
pretty: bool,
},
Ops {
format: Option<MetricsFormat>,
pretty: bool,
},
StripComments {
in_place: bool,
},
Functions,
Find(Arc<[String]>),
Count(Arc<[String]>),
Report,
PreprocProduce,
Check,
}
#[derive(Debug)]
struct Config {
action: Action,
output: Option<PathBuf>,
language: Option<LANG>,
line_start: Option<usize>,
line_end: Option<usize>,
preproc_lock: Option<Arc<Mutex<PreprocResults>>>,
preproc: Option<Arc<PreprocResults>>,
count_lock: Option<Arc<Mutex<Count>>>,
markdown_tx: Option<Mutex<std::sync::mpsc::Sender<FunctionSummary>>>,
strip_prefix: String,
threshold_set: Option<Arc<ThresholdSet>>,
check_tx: Option<Mutex<std::sync::mpsc::Sender<Violation>>>,
files_dispatched: Option<Arc<AtomicUsize>>,
suppression_policy: SuppressionPolicy,
warning: bool,
skip_generated: bool,
report_skipped: bool,
exclude_tests: bool,
}
impl Config {
fn new(action: Action, globals: &GlobalOpts, preproc: Option<Arc<PreprocResults>>) -> Self {
let language = resolve_language(globals.language_type.as_deref(), &action);
Self {
action,
output: None,
language,
line_start: globals.line_start,
line_end: globals.line_end,
preproc_lock: None,
preproc,
count_lock: None,
markdown_tx: None,
strip_prefix: String::new(),
threshold_set: None,
check_tx: None,
files_dispatched: None,
suppression_policy: SuppressionPolicy::Honor,
warning: globals.warning,
skip_generated: !globals.no_skip_generated,
report_skipped: globals.report_skipped,
exclude_tests: globals.exclude_tests,
}
}
#[inline]
fn metrics_options(&self) -> MetricsOptions {
MetricsOptions::default().with_exclude_tests(self.exclude_tests)
}
}
fn mk_globset(elems: Vec<String>) -> Result<GlobSet, String> {
if elems.is_empty() {
return Ok(GlobSet::empty());
}
let mut globset = GlobSetBuilder::new();
for e in &elems {
if e.is_empty() {
continue;
}
globset.add(Glob::new(e).map_err(|err| format!("invalid glob pattern {e:?}: {err}"))?);
}
globset
.build()
.map_err(|err| format!("failed to build glob set: {err}"))
}
#[allow(deprecated)]
fn act_on_file(path: PathBuf, cfg: &Config) -> std::io::Result<()> {
if let Some(counter) = &cfg.files_dispatched {
counter.fetch_add(1, Ordering::Relaxed);
}
let Some(source) = read_file_with_eol(&path)? else {
if cfg.warning {
eprintln!("warning: skipping empty file: {}", path.display());
}
return Ok(());
};
if cfg.skip_generated && !matches!(cfg.action, Action::PreprocProduce) && is_generated(&source)
{
if cfg.report_skipped || cfg.warning {
eprintln!("skipped (generated): {}", path.display());
}
return Ok(());
}
let Some(language) = cfg.language.or_else(|| guess_language(&source, &path).0) else {
if cfg.warning {
eprintln!(
"warning: skipping file with unrecognized language: {}",
path.display()
);
}
return Ok(());
};
let pr = cfg.preproc.clone();
match &cfg.action {
Action::Dump => {
let dump_cfg = DumpCfg {
line_start: cfg.line_start,
line_end: cfg.line_end,
};
action::<Dump>(&language, source, &path, pr, dump_cfg).expect(FEATURES_PINNED)
}
Action::Metrics { format, pretty } => {
if let Some(fmt) = format {
if let Ok(space) = get_function_spaces_with_options(
&language,
source,
&path,
pr,
cfg.metrics_options(),
) {
match fmt.dispatch() {
MetricsDispatch::Generic(g) => {
g.dump(space, path, cfg.output.as_ref(), *pretty)?;
}
MetricsDispatch::Csv => {
dump_csv(&space, path, cfg.output.as_ref())?;
}
}
}
Ok(())
} else {
let metrics_cfg = MetricsCfg::new(path).with_options(cfg.metrics_options());
let path = metrics_cfg.path.clone();
action::<Metrics>(&language, source, &path, pr, metrics_cfg).expect(FEATURES_PINNED)
}
}
Action::Ops { format, pretty } => {
if let Some(fmt) = format {
if let Ok(ops) = get_ops(&language, source, &path, pr) {
match fmt.dispatch() {
MetricsDispatch::Generic(g) => {
g.dump(ops, path, cfg.output.as_ref(), *pretty)?;
}
MetricsDispatch::Csv => {}
}
}
Ok(())
} else {
let ops_cfg = OpsCfg { path };
let path = ops_cfg.path.clone();
action::<OpsCode>(&language, source, &path, pr, ops_cfg).expect(FEATURES_PINNED)
}
}
Action::StripComments { in_place } => {
let comment_cfg = CommentRmCfg {
in_place: *in_place,
path,
};
let path = comment_cfg.path.clone();
let lang = if language == LANG::Cpp {
LANG::Ccomment
} else {
language
};
action::<CommentRm>(&lang, source, &path, pr, comment_cfg).expect(FEATURES_PINNED)
}
Action::Functions => {
let fn_cfg = FunctionCfg { path: path.clone() };
action::<Function>(&language, source, &path, pr, fn_cfg).expect(FEATURES_PINNED)
}
Action::Find(filters) => {
let find_cfg = FindCfg {
path: path.clone(),
filters: Arc::clone(filters),
line_start: cfg.line_start,
line_end: cfg.line_end,
};
action::<Find>(&language, source, &path, pr, find_cfg).expect(FEATURES_PINNED)
}
Action::Count(filters) => {
let stats = cfg
.count_lock
.clone()
.expect("Count handler initializes count_lock before dispatch");
let count_cfg = CountCfg {
filters: Arc::clone(filters),
stats,
};
action::<Count>(&language, source, &path, pr, count_cfg).expect(FEATURES_PINNED)
}
Action::Report => {
if let Ok(space) = get_function_spaces_with_options(
&language,
source,
&path,
pr,
cfg.metrics_options(),
) && let Some(ref tx) = cfg.markdown_tx
&& !matches!(language, LANG::Preproc | LANG::Ccomment)
{
let Some(file_str) = path.to_str() else {
if cfg.warning {
eprintln!(
"warning: skipping non-UTF-8 path in report: {}",
path.display()
);
}
return Ok(());
};
let mut summaries = Vec::new();
extract_summaries(
&space,
file_str,
language,
&cfg.strip_prefix,
&mut summaries,
);
let Ok(sender) = tx.lock() else {
if cfg.warning {
eprintln!(
"warning: skipping {}: report channel lock poisoned",
path.display()
);
}
return Ok(());
};
for s in summaries {
let _ = sender.send(s);
}
}
Ok(())
}
Action::Check => {
if let Ok(space) = get_function_spaces_with_options(
&language,
source,
&path,
pr,
cfg.metrics_options(),
) && let (Some(set), Some(tx)) = (cfg.threshold_set.as_ref(), cfg.check_tx.as_ref())
&& !matches!(language, LANG::Preproc | LANG::Ccomment)
{
let mut violations = Vec::new();
set.evaluate_with_policy(&path, &space, cfg.suppression_policy, &mut violations);
if !violations.is_empty() {
let Ok(sender) = tx.lock() else {
if cfg.warning {
eprintln!(
"warning: skipping {}: check channel lock poisoned",
path.display()
);
}
return Ok(());
};
for v in violations {
let _ = sender.send(v);
}
}
}
Ok(())
}
Action::PreprocProduce => {
if let Some(preproc_lock) = &cfg.preproc_lock
&& let Some(language) = guess_language(&source, &path).0
&& language == LANG::Cpp
{
let mut results = preproc_lock.lock().expect("mutex not poisoned");
preprocess(
&PreprocParser::new(source, &path, None),
&path,
&mut results,
);
}
Ok(())
}
}
}
fn process_dir_path(all_files: &mut HashMap<String, Vec<PathBuf>>, path: &Path, cfg: &Config) {
if !matches!(cfg.action, Action::PreprocProduce) {
return;
}
let Some(fname) = path.file_name().and_then(|n| n.to_str()) else {
return;
};
let file_name = fname.to_string();
match all_files.entry(file_name) {
hash_map::Entry::Occupied(l) => {
l.into_mut().push(path.to_path_buf());
}
hash_map::Entry::Vacant(p) => {
p.insert(vec![path.to_path_buf()]);
}
}
}
fn resolve_language(typ: Option<&str>, action: &Action) -> Option<LANG> {
if matches!(action, Action::PreprocProduce) {
return Some(LANG::Preproc);
}
match typ.unwrap_or("") {
"" => None,
"ccomment" => Some(LANG::Ccomment),
"preproc" => Some(LANG::Preproc),
other => get_from_ext(other),
}
}
fn resolve_num_jobs(requested: Option<usize>) -> usize {
requested.map_or_else(
|| {
std::cmp::max(
2,
available_parallelism()
.unwrap_or_else(|e| {
die(format_args!("could not get available parallelism: {e}"))
})
.get(),
) - 1
},
|num_jobs| std::cmp::max(2, num_jobs) - 1,
)
}
fn load_preproc_data(path: &Path) -> Arc<PreprocResults> {
let data = read_file(path).unwrap_or_else(|e| die_io("read preproc data", path, e));
let parsed = serde_json::from_slice::<PreprocResults>(&data)
.unwrap_or_else(|e| die_io("parse preproc JSON from", path, e));
Arc::new(parsed)
}
fn read_paths_from(src: &Path) -> Vec<PathBuf> {
if src.as_os_str() == "-" {
collect_path_lines(std::io::stdin().lock(), "--paths-from -")
} else {
let label = format!("--paths-from {}", src.display());
let f = std::fs::File::open(src).unwrap_or_else(|e| die(format_args!("{label}: {e}")));
collect_path_lines(std::io::BufReader::new(f), &label)
}
}
fn collect_path_lines<R: std::io::BufRead>(reader: R, label: &str) -> Vec<PathBuf> {
reader
.lines()
.enumerate()
.filter_map(|(i, r)| {
let line = r.unwrap_or_else(|e| {
die(format_args!("{label}: read error on line {}: {e}", i + 1))
});
let trimmed = line.trim();
(!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
})
.collect()
}
fn expand_seed_paths(
paths: Vec<PathBuf>,
paths_from: Option<PathBuf>,
no_ignore: bool,
) -> Vec<PathBuf> {
use ignore::WalkBuilder;
let mut seeds = paths;
if let Some(src) = paths_from {
seeds.extend(read_paths_from(&src));
}
let mut out: Vec<PathBuf> = Vec::new();
for seed in seeds {
if !seed.exists() {
eprintln!("Warning: File doesn't exist: {}", seed.display());
continue;
}
if seed.is_file() {
out.push(seed);
continue;
}
let mut wb = WalkBuilder::new(&seed);
wb.hidden(true)
.follow_links(false)
.require_git(false)
.git_ignore(!no_ignore)
.git_exclude(!no_ignore)
.git_global(!no_ignore)
.ignore(!no_ignore)
.parents(!no_ignore);
for entry in wb.build() {
let entry = entry
.unwrap_or_else(|e| die(format_args!("walk error in {}: {e}", seed.display())));
if entry.file_type().is_some_and(|t| t.is_file()) {
out.push(entry.into_path());
}
}
}
out
}
fn run_walk(globals: GlobalOpts, cfg: Config) -> HashMap<String, Vec<PathBuf>> {
let include = mk_globset(globals.include).unwrap_or_else(|e| die(e));
let exclude = mk_globset(globals.exclude).unwrap_or_else(|e| die(e));
let num_jobs = resolve_num_jobs(globals.num_jobs);
let paths = expand_seed_paths(globals.paths, globals.paths_from, globals.no_ignore);
let files_data = FilesData {
include,
exclude,
paths,
};
ConcurrentRunner::new(num_jobs, act_on_file)
.set_proc_dir_paths(process_dir_path)
.run(cfg, files_data)
.unwrap_or_else(|e| die(format_args!("{e:?}")))
}
fn load_threshold_config(path: &Path) -> BTreeMap<String, f64> {
let bytes = read_file(path).unwrap_or_else(|e| die_io("read threshold config", path, e));
let text = std::str::from_utf8(&bytes)
.unwrap_or_else(|e| die_io("decode UTF-8 from threshold config", path, e));
let cfg: ThresholdConfig =
toml::from_str(text).unwrap_or_else(|e| die_io("parse threshold config", path, e));
cfg.thresholds
}
fn load_baseline(path: &Path) -> Baseline {
let bytes = read_file(path).unwrap_or_else(|e| die_io("read baseline", path, e));
let text = std::str::from_utf8(&bytes)
.unwrap_or_else(|e| die_io("decode UTF-8 from baseline", path, e));
Baseline::from_str(text).unwrap_or_else(|e| die_io("parse baseline", path, e))
}
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let mut tmp = path.as_os_str().to_os_string();
tmp.push(".bca-tmp");
let tmp = PathBuf::from(tmp);
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
}
fn run_check(globals: GlobalOpts, args: CheckArgs, preproc: Option<Arc<PreprocResults>>) {
if let Some(fmt) = args.output_format
&& let Some(ref out) = args.output
&& out.exists()
&& out.is_dir()
{
die(format_args!(
"--output must be a file path for `check --output-format {}`",
fmt.name()
));
}
let mut merged: BTreeMap<String, f64> = args
.config
.as_deref()
.map(load_threshold_config)
.unwrap_or_default();
for (name, limit) in args.thresholds {
merged.insert(name, limit);
}
let set = ThresholdSet::build(&merged).unwrap_or_else(|e| die(e));
if set.is_empty() {
die("no thresholds configured; pass --threshold or --config");
}
let set = Arc::new(set);
let (tx, rx) = std::sync::mpsc::channel();
let files_dispatched = Arc::new(AtomicUsize::new(0));
let cfg = Config {
threshold_set: Some(Arc::clone(&set)),
check_tx: Some(Mutex::new(tx)),
files_dispatched: Some(Arc::clone(&files_dispatched)),
suppression_policy: SuppressionPolicy::from_no_suppress(args.no_suppress),
..Config::new(Action::Check, &globals, preproc)
};
run_walk(globals, cfg);
if files_dispatched.load(Ordering::Relaxed) == 0 {
die("bca check: no input files matched; check --paths, --include, --exclude");
}
let mut violations: Vec<Violation> = rx.into_iter().collect();
violations.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.start_line.cmp(&b.start_line))
.then(a.metric.cmp(b.metric))
});
if let Some(path) = args.write_baseline {
let file = baseline::from_violations(violations);
let entry_count = file.entries.len();
let text = baseline::render(&file)
.unwrap_or_else(|e| die(format_args!("serialize baseline: {e}")));
write_atomic(&path, text.as_bytes()).unwrap_or_else(|e| die_io("write baseline", &path, e));
eprintln!(
"bca: wrote {entry_count} baseline entries to {}",
path.display()
);
return;
}
let violations: Vec<Violation> = if let Some(path) = args.baseline.as_deref() {
let baseline = load_baseline(path);
let before = violations.len();
let kept: Vec<Violation> = violations
.into_iter()
.filter(|v| !baseline.covers(v))
.collect();
let filtered = before - kept.len();
if filtered > 0 {
eprintln!("bca: filtered {filtered} violations via baseline");
}
kept
} else {
violations
};
let mut stderr = std::io::stderr().lock();
for v in &violations {
let _ = writeln!(stderr, "{v}");
}
let any_violations = !violations.is_empty();
if let Some(fmt) = args.output_format {
let offenders: Vec<_> = violations.into_iter().map(violation_to_offender).collect();
fmt.dump(&offenders, args.output.as_deref())
.unwrap_or_else(|e| die(format_args!("failed to write {}: {e}", fmt.name())));
}
if any_violations && !args.no_fail {
process::exit(2);
}
}
pub fn run() {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => {
if matches!(
err.kind(),
clap::error::ErrorKind::UnknownArgument
| clap::error::ErrorKind::InvalidSubcommand
| clap::error::ErrorKind::InvalidValue
| clap::error::ErrorKind::MissingSubcommand
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
) && let Some(hint) = legacy_hint(std::env::args_os())
{
eprintln!("{hint}");
}
err.exit();
}
};
let preproc = cli
.globals
.preproc_data
.as_ref()
.map(|p| load_preproc_data(p));
match cli.command {
Command::ListMetrics(args) => {
let mut buf = Vec::new();
write_metrics(&mut buf, args.mode).expect("writing to Vec<u8> is infallible");
write_stdout_or_die(&buf);
}
Command::Dump => {
let cfg = Config::new(Action::Dump, &cli.globals, preproc);
run_walk(cli.globals, cfg);
}
Command::Functions => {
let cfg = Config::new(Action::Functions, &cli.globals, preproc);
run_walk(cli.globals, cfg);
}
Command::Metrics(args) => {
if matches!(args.output_format, Some(MetricsFormat::Cbor)) && args.output.is_none() {
die(CBOR_STDOUT_ERROR);
}
if args.output_format.is_some()
&& let Some(ref out) = args.output
&& out.exists()
&& !out.is_dir()
{
die("--output must be a directory for `metrics`");
}
let action = Action::Metrics {
format: args.output_format,
pretty: args.pretty,
};
let cfg = Config {
output: args.output,
..Config::new(action, &cli.globals, preproc)
};
run_walk(cli.globals, cfg);
}
Command::Ops(args) => {
if matches!(args.output_format, Some(MetricsFormat::Cbor)) && args.output.is_none() {
die(CBOR_STDOUT_ERROR);
}
if let Some(MetricsDispatch::Csv) = args.output_format.map(MetricsFormat::dispatch) {
die(
"CSV is not supported by `ops` because its column schema is metric-shaped; use `bca metrics --output-format <fmt>`",
);
}
if args.output_format.is_some()
&& let Some(ref out) = args.output
&& out.exists()
&& !out.is_dir()
{
die("--output must be a directory for `ops`");
}
let action = Action::Ops {
format: args.output_format,
pretty: args.pretty,
};
let cfg = Config {
output: args.output,
..Config::new(action, &cli.globals, preproc)
};
run_walk(cli.globals, cfg);
}
Command::Report(args) => {
if let Some(ref output) = args.output {
if output.exists() && output.is_dir() {
die("--output must be a file path for `report`");
}
if let Some(parent) = output.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
die(format_args!(
"parent directory of --output does not exist: {}",
parent.display()
));
}
}
let (tx, rx) = std::sync::mpsc::channel();
let cfg = Config {
markdown_tx: Some(Mutex::new(tx)),
strip_prefix: args.strip_prefix,
..Config::new(Action::Report, &cli.globals, preproc)
};
run_walk(cli.globals, cfg);
let summaries: Vec<FunctionSummary> = rx.into_iter().collect();
let report = match args.format {
ReportFormat::Markdown => generate_report(&summaries, args.top as usize),
ReportFormat::Html => generate_html_report(&summaries, args.top as usize),
};
if let Some(ref output_path) = args.output {
std::fs::write(output_path, &report)
.unwrap_or_else(|e| die_io("write report to", output_path, e));
} else {
write_stdout_or_die(report.as_bytes());
}
}
Command::Find(args) => {
let cfg = Config::new(Action::Find(args.nodes.into()), &cli.globals, preproc);
run_walk(cli.globals, cfg);
}
Command::Count(args) => {
let count_lock = Arc::new(Mutex::new(Count::default()));
let cfg = Config {
count_lock: Some(count_lock.clone()),
..Config::new(Action::Count(args.nodes.into()), &cli.globals, preproc)
};
run_walk(cli.globals, cfg);
let count = Arc::try_unwrap(count_lock)
.expect("all worker threads have joined; Arc refcount is 1")
.into_inner()
.expect("mutex not poisoned");
println!("{count}");
}
Command::StripComments(args) => {
let action = Action::StripComments {
in_place: args.in_place,
};
let cfg = Config::new(action, &cli.globals, preproc);
run_walk(cli.globals, cfg);
}
Command::Check(args) => {
run_check(cli.globals, args, preproc);
}
Command::Preproc(args) => {
let preproc_lock = Arc::new(Mutex::new(PreprocResults::default()));
let output = args.output;
let cfg = Config {
preproc_lock: Some(preproc_lock.clone()),
..Config::new(Action::PreprocProduce, &cli.globals, None)
};
let all_files = run_walk(cli.globals, cfg);
let mut data = Arc::try_unwrap(preproc_lock)
.expect("all worker threads have joined; Arc refcount is 1")
.into_inner()
.expect("mutex not poisoned");
fix_includes(&mut data.files, &all_files);
let serialized = serde_json::to_string(&data)
.unwrap_or_else(|e| die(format_args!("failed to serialize preproc data: {e}")));
if let Some(output_path) = output {
write_file(&output_path, serialized.as_bytes())
.unwrap_or_else(|e| die_io("write preproc output to", &output_path, e));
} else {
println!("{serialized}");
}
}
}
}
const SUBCOMMANDS: &[&str] = &[
"metrics",
"ops",
"report",
"dump",
"find",
"count",
"functions",
"strip-comments",
"preproc",
"list-metrics",
"check",
];
fn parse_output_format_value(args: &[String]) -> Option<&str> {
args.iter().enumerate().find_map(|(i, a)| {
let s = a.as_str();
if s == "-O" || s == "--output-format" {
args.get(i + 1).map(String::as_str)
} else if let Some(rest) = s.strip_prefix("--output-format=") {
Some(rest)
} else {
s.strip_prefix("-O").filter(|r| !r.is_empty())
}
})
}
fn offender_format_migration_hint(args: &[String]) -> Option<String> {
let fmt =
parse_output_format_value(args).filter(|f| AggregatedFormat::from_str(f, true).is_ok())?;
Some(format!(
"note: -O {fmt} moved to `bca check` in #235; offender formats are no longer accepted on `bca metrics` / `bca ops`.\n bca metrics -O {fmt} ... -> bca check --threshold <metric>=<limit> --output-format {fmt} [--output FILE]\n Run `bca check --help` for the threshold and output-format flags.\n"
))
}
fn legacy_hint(argv: impl IntoIterator<Item = OsString>) -> Option<String> {
let args: Vec<String> = argv
.into_iter()
.skip(1) .filter_map(|s| s.into_string().ok())
.collect();
if args.is_empty() {
return None;
}
if let Some(sub) = args.iter().find(|a| SUBCOMMANDS.contains(&a.as_str())) {
if matches!(sub.as_str(), "metrics" | "ops")
&& let Some(hint) = offender_format_migration_hint(&args)
{
return Some(hint);
}
return None;
}
let action_map: &[(&str, &str)] = &[
("--metrics", "bca metrics"),
("-m", "bca metrics"),
("--ops", "bca ops"),
("--dump", "bca dump"),
("-d", "bca dump"),
("--comments", "bca strip-comments [--in-place]"),
("--function", "bca functions"),
("-F", "bca functions"),
("--find", "bca find <NODE> [<NODE>...]"),
("-f", "bca find <NODE> [<NODE>...]"),
("--count", "bca count <NODE> [<NODE>...]"),
("-C", "bca count <NODE> [<NODE>...]"),
("--list-metrics", "bca list-metrics [names|descriptions]"),
(
"--preproc",
"bca preproc -o OUT.json (or --preproc-data on consumers)",
),
];
let mut lines: Vec<String> = Vec::new();
let mut saw_legacy_action = false;
for arg in &args {
let head = arg.split('=').next().unwrap_or(arg);
if let Some((_, replacement)) = action_map.iter().find(|(old, _)| *old == head) {
saw_legacy_action = true;
lines.push(format!(" {head} -> {replacement}"));
}
}
let format_value = parse_output_format_value(&args);
if format_value == Some("markdown") {
saw_legacy_action = true;
lines.push(String::from(
" -O markdown -> bca report markdown|html [--top N] [--strip-prefix P]",
));
} else if let Some(fmt) = format_value
&& saw_legacy_action
{
lines.push(format!(" -O {fmt} -> bca metrics -O {fmt}"));
}
if !saw_legacy_action {
return None;
}
let mut hint = String::from(
"note: the CLI was restructured into subcommands. See migration.md for the full mapping.\n",
);
for line in &lines {
hint.push_str(line);
hint.push('\n');
}
hint.push_str(" Run `bca --help` for the new command list.\n");
Some(hint)
}
#[cfg(test)]
#[allow(
clippy::float_cmp,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::similar_names,
clippy::doc_markdown,
clippy::needless_raw_string_hashes,
clippy::too_many_lines
)]
mod tests {
use super::*;
fn test_config(action: Action) -> Config {
Config {
action,
output: None,
language: None,
line_start: None,
line_end: None,
preproc_lock: None,
preproc: None,
count_lock: None,
markdown_tx: None,
strip_prefix: String::new(),
threshold_set: None,
check_tx: None,
files_dispatched: None,
suppression_policy: SuppressionPolicy::Honor,
warning: false,
skip_generated: true,
report_skipped: false,
exclude_tests: false,
}
}
#[test]
fn process_dir_path_noop_outside_preproc() {
let cfg = test_config(Action::Dump);
let mut all_files = HashMap::new();
process_dir_path(&mut all_files, Path::new("/some/file.cpp"), &cfg);
assert!(all_files.is_empty());
}
#[test]
fn process_dir_path_inserts_valid_utf8_filename() {
let cfg = test_config(Action::PreprocProduce);
let mut all_files = HashMap::new();
process_dir_path(&mut all_files, Path::new("/some/dir/foo.cpp"), &cfg);
assert_eq!(all_files.len(), 1);
assert_eq!(
all_files["foo.cpp"],
vec![PathBuf::from("/some/dir/foo.cpp")]
);
}
#[test]
fn process_dir_path_groups_duplicate_filenames() {
let cfg = test_config(Action::PreprocProduce);
let mut all_files = HashMap::new();
process_dir_path(&mut all_files, Path::new("/a/foo.cpp"), &cfg);
process_dir_path(&mut all_files, Path::new("/b/foo.cpp"), &cfg);
assert_eq!(all_files.len(), 1);
assert_eq!(
all_files["foo.cpp"],
vec![PathBuf::from("/a/foo.cpp"), PathBuf::from("/b/foo.cpp")]
);
}
#[cfg(unix)]
#[test]
fn process_dir_path_skips_non_utf8_filename() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let cfg = test_config(Action::PreprocProduce);
let mut all_files = HashMap::new();
let bad_name = OsStr::from_bytes(b"\xff\xfe");
let path = PathBuf::from("/some/dir").join(bad_name);
process_dir_path(&mut all_files, &path, &cfg);
assert!(all_files.is_empty());
}
fn parse(args: &[&str]) -> clap::error::Result<Cli> {
Cli::try_parse_from(std::iter::once(&"cli").chain(args.iter()))
}
#[test]
fn no_subcommand_prints_help() {
assert!(parse(&[]).is_err());
}
#[test]
fn metrics_alone_parses() {
assert!(parse(&["metrics"]).is_ok());
}
#[test]
fn metrics_with_format_parses() {
assert!(parse(&["metrics", "-O", "json"]).is_ok());
}
#[test]
fn metrics_rejects_checkstyle_format() {
assert!(parse(&["metrics", "-O", "checkstyle"]).is_err());
}
#[test]
fn metrics_rejects_sarif_format() {
assert!(parse(&["metrics", "-O", "sarif"]).is_err());
}
#[test]
fn metrics_rejects_clang_warning_format() {
assert!(parse(&["metrics", "-O", "clang-warning"]).is_err());
}
#[test]
fn metrics_rejects_msvc_warning_format() {
assert!(parse(&["metrics", "-O", "msvc-warning"]).is_err());
}
#[test]
fn check_accepts_sarif_output_format() {
assert!(parse(&["check", "--threshold", "cyclomatic=10", "-O", "sarif"]).is_ok());
}
#[test]
fn check_accepts_checkstyle_output_format() {
assert!(
parse(&[
"check",
"--threshold",
"cyclomatic=10",
"--output-format",
"checkstyle",
])
.is_ok()
);
}
#[test]
fn check_rejects_per_file_format_as_output_format() {
assert!(
parse(&[
"check",
"--threshold",
"cyclomatic=10",
"--output-format",
"json",
])
.is_err()
);
}
#[test]
fn metrics_rejects_markdown_format() {
assert!(parse(&["metrics", "-O", "markdown"]).is_err());
}
#[test]
fn metrics_rejects_top_flag() {
assert!(parse(&["metrics", "--top", "5"]).is_err());
}
#[test]
fn metrics_rejects_strip_prefix_flag() {
assert!(parse(&["metrics", "--strip-prefix", "/x"]).is_err());
}
#[test]
fn report_markdown_parses() {
assert!(parse(&["report", "markdown"]).is_ok());
}
#[test]
fn report_html_parses() {
let cli = parse(&["report", "html"]).expect("`report html` parses");
match cli.command {
Command::Report(args) => assert_eq!(args.format, ReportFormat::Html),
other => panic!("expected Command::Report, got {other:?}"),
}
}
#[test]
fn report_requires_format() {
assert!(parse(&["report"]).is_err());
}
#[test]
fn report_with_top_and_strip_prefix() {
assert!(parse(&["report", "markdown", "--top", "10", "--strip-prefix", "/x/"]).is_ok());
}
#[test]
fn report_html_with_top_and_strip_prefix() {
let cli = parse(&["report", "html", "--top", "10", "--strip-prefix", "/x/"])
.expect("flags parse");
match cli.command {
Command::Report(args) => {
assert_eq!(args.format, ReportFormat::Html);
assert_eq!(args.top, 10);
assert_eq!(args.strip_prefix, "/x/");
}
other => panic!("expected Command::Report, got {other:?}"),
}
}
#[test]
fn report_top_zero_rejected() {
assert!(parse(&["report", "markdown", "--top", "0"]).is_err());
}
#[test]
fn report_html_top_zero_rejected() {
assert!(parse(&["report", "html", "--top", "0"]).is_err());
}
#[test]
fn ops_parses() {
assert!(parse(&["ops", "-O", "json"]).is_ok());
}
#[test]
fn dump_parses() {
assert!(parse(&["dump"]).is_ok());
}
#[test]
fn find_requires_a_node() {
assert!(parse(&["find"]).is_err());
assert!(parse(&["find", "call_expression"]).is_ok());
}
#[test]
fn count_requires_a_node() {
assert!(parse(&["count"]).is_err());
assert!(parse(&["count", "if_statement"]).is_ok());
}
#[test]
fn functions_parses() {
assert!(parse(&["functions"]).is_ok());
}
#[test]
fn strip_comments_parses() {
assert!(parse(&["strip-comments"]).is_ok());
assert!(parse(&["strip-comments", "--in-place"]).is_ok());
}
#[test]
fn preproc_parses() {
assert!(parse(&["preproc"]).is_ok());
assert!(parse(&["preproc", "-o", "/tmp/x.json"]).is_ok());
}
#[test]
fn list_metrics_parses() {
let cli = parse(&["list-metrics"]).expect("parses");
assert!(matches!(cli.command, Command::ListMetrics(_)));
}
#[test]
fn list_metrics_with_descriptions() {
let cli = parse(&["list-metrics", "descriptions"]).expect("parses");
match cli.command {
Command::ListMetrics(args) => assert_eq!(args.mode, ListMetricsMode::Descriptions),
_ => panic!("expected ListMetrics"),
}
}
#[test]
fn list_metrics_invalid_mode_rejected() {
assert!(parse(&["list-metrics", "bogus"]).is_err());
}
#[test]
fn global_paths_works_before_or_after_subcommand() {
assert!(parse(&["--paths", "x", "metrics"]).is_ok());
assert!(parse(&["metrics", "--paths", "x"]).is_ok());
}
fn os_args(args: &[&str]) -> Vec<OsString> {
args.iter().map(|s| OsString::from(*s)).collect()
}
#[test]
fn legacy_hint_recognizes_old_metrics() {
let hint = legacy_hint(os_args(&["cli", "--metrics", "-O", "markdown"])).expect("hint");
assert!(hint.contains("report markdown"), "{hint}");
assert!(hint.contains("--metrics"), "{hint}");
}
#[test]
fn legacy_hint_recognizes_output_format_json_with_legacy_action() {
let hint = legacy_hint(os_args(&["cli", "-m", "--output-format", "json"])).expect("hint");
assert!(hint.contains("metrics -O json"), "{hint}");
}
#[test]
fn legacy_hint_returns_none_for_clean_args() {
let hint = legacy_hint(os_args(&["cli", "metrics", "-O", "json"]));
assert!(hint.is_none());
}
#[test]
fn legacy_hint_returns_none_for_no_args() {
let hint = legacy_hint(os_args(&["cli"]));
assert!(hint.is_none());
}
#[test]
fn legacy_hint_recognizes_dash_o_markdown_alone() {
let hint = legacy_hint(os_args(&["cli", "-O", "markdown"])).expect("hint");
assert!(hint.contains("report markdown"), "{hint}");
}
#[test]
fn legacy_hint_redirects_metrics_offender_format_to_check() {
let hint = legacy_hint(os_args(&["cli", "metrics", "-O", "sarif"])).expect("hint");
assert!(hint.contains("bca check"), "{hint}");
assert!(hint.contains("sarif"), "{hint}");
}
#[test]
fn legacy_hint_redirects_metrics_checkstyle_long_form() {
let hint = legacy_hint(os_args(&[
"cli",
"metrics",
"--output-format",
"checkstyle",
]))
.expect("hint");
assert!(hint.contains("bca check"), "{hint}");
assert!(hint.contains("checkstyle"), "{hint}");
}
#[test]
fn legacy_hint_redirects_ops_offender_format_to_check() {
let hint = legacy_hint(os_args(&["cli", "ops", "-O", "clang-warning"])).expect("hint");
assert!(hint.contains("bca check"), "{hint}");
assert!(hint.contains("clang-warning"), "{hint}");
}
#[test]
fn legacy_hint_quiet_for_metrics_with_per_file_format() {
let hint = legacy_hint(os_args(&["cli", "metrics", "-O", "json"]));
assert!(hint.is_none(), "{hint:?}");
}
#[test]
fn legacy_hint_quiet_when_user_invoked_known_subcommand() {
let hint = legacy_hint(os_args(&["cli", "find", "--dump"]));
assert!(hint.is_none());
}
#[test]
fn legacy_hint_recognizes_dash_d() {
let hint = legacy_hint(os_args(&["cli", "-d", "--paths", "."])).expect("hint");
assert!(hint.contains("bca dump"), "{hint}");
}
#[test]
fn cli_is_well_formed() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn subcommands_match_command_enum() {
use clap::CommandFactory;
use std::collections::HashSet;
let from_clap: HashSet<String> = Cli::command()
.get_subcommands()
.map(|c| c.get_name().to_string())
.filter(|n| n != "help") .collect();
let from_const: HashSet<String> = SUBCOMMANDS.iter().map(|s| (*s).to_string()).collect();
assert_eq!(
from_clap,
from_const,
"SUBCOMMANDS const drifted from Command enum: \
missing from const = {missing:?}, missing from enum = {extra:?}",
missing = from_clap.difference(&from_const).collect::<Vec<_>>(),
extra = from_const.difference(&from_clap).collect::<Vec<_>>(),
);
}
}