use crate::{
atomic::{WritePlan, apply_transaction},
config, files, git, interactive, lsp,
output::{
self, Explanations, FileExplanation, Operation, OutputFormat, Presentation, ProcessedFile,
ProcessedResult, RenderOptions, Verbosity,
},
plugin,
values::{CommentKindArg, DialectArg, LanguageArg, LayoutArg, PolicyArg},
};
use anyhow::{Context, Result, bail, ensure};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use ocomment_core::{CommentKind, Dialect, Language, PreparedScanner, transform};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::{
borrow::Cow,
collections::{BTreeMap, HashMap},
fs,
io::{self, IsTerminal, Read, Write},
path::PathBuf,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
};
const LONG_ABOUT: &str = "\
OComment scans source bytes without requiring UTF-8 and reports or removes \
comment tokens. The default policy protects source preambles and tool or \
language directives. Rewrites are prepared and committed as one \
rollback-backed transaction.";
const AFTER_LONG_HELP: &str = "\
EXIT STATUS
0 Nothing removable was found and every requested change was applied.
1 Removable comments were reported, or a diff was printed.
2 Invalid source, configuration, plugin, or I/O failure.
FILES
.ocomment.toml Project configuration, merged over the user file.
.ocommentignore Extra ignore patterns honoured by repository walks.
.ocomment.lock Pinned digests of the installed WASM scanner plugins.
$XDG_CONFIG_HOME/ocomment/config.toml
User configuration, merged over the built-in defaults.
EXAMPLES
ocomment
Check the current directory and report removable comments.
ocomment fix --policy all --layout compact src
Remove every comment under src and close the gaps it leaves.
ocomment strip --language rust < before.rs > after.rs
Strip one file from standard input to standard output.
SEE ALSO
The complete schemas and guides are available in the OComment repository.";
const MAN_SECTIONS: &str = r#".SH EXIT STATUS
.TP
.B 0
Nothing removable was found and every requested change was applied.
.TP
.B 1
Removable comments were reported, or a diff was printed.
.TP
.B 2
Invalid source, configuration, plugin, or I/O failure.
.SH FILES
.TP
.B \&.ocomment.toml
Project configuration, merged over the user file.
.TP
.B \&.ocommentignore
Extra ignore patterns honoured by repository walks.
.TP
.B \&.ocomment.lock
Pinned digests of the installed WASM scanner plugins.
.TP
.B $XDG_CONFIG_HOME/ocomment/config.toml
User configuration, merged over the built\-in defaults.
.SH EXAMPLES
.TP
.B ocomment
Check the current directory and report removable comments.
.TP
.B ocomment fix \-\-policy all \-\-layout compact src
Remove every comment under src and close the gaps it leaves.
.TP
.B ocomment strip \-\-language rust < before.rs > after.rs
Strip one file from standard input to standard output.
.SH SEE ALSO
The complete schemas and guides are available in the OComment repository.
"#;
#[derive(Parser)]
#[command(
name = "ocomment",
version,
about = "Check and remove source-code comments safely"
)]
#[command(long_about = LONG_ABOUT)]
#[command(args_conflicts_with_subcommands = true)]
#[command(after_long_help = AFTER_LONG_HELP)]
struct Cli {
#[arg(value_name = "PATH")]
paths: Vec<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
common: CommonArgs,
}
#[derive(Clone, Debug, Args)]
struct CommonArgs {
#[arg(long, global = true, value_name = "FILE")]
config: Option<PathBuf>,
#[command(flatten)]
policy: PolicyArgs,
#[command(flatten)]
output: OutputArgs,
}
#[derive(Clone, Debug, Args)]
#[command(next_help_heading = "Policy")]
struct PolicyArgs {
#[arg(
long,
global = true,
value_enum,
ignore_case = true,
value_name = "POLICY"
)]
policy: Option<PolicyArg>,
#[arg(
long,
global = true,
value_enum,
ignore_case = true,
value_name = "LAYOUT"
)]
layout: Option<LayoutArg>,
#[arg(
long,
global = true,
value_enum,
ignore_case = true,
value_name = "LANGUAGE"
)]
language: Option<LanguageArg>,
#[arg(
long,
global = true,
value_enum,
ignore_case = true,
value_name = "DIALECT"
)]
dialect: Option<DialectArg>,
#[arg(
long = "keep-kind",
global = true,
value_enum,
ignore_case = true,
value_delimiter = ',',
value_name = "KIND"
)]
keep_kind: Vec<CommentKindArg>,
#[arg(
long = "remove-kind",
global = true,
value_enum,
ignore_case = true,
value_delimiter = ',',
value_name = "KIND"
)]
remove_kind: Vec<CommentKindArg>,
#[arg(long, global = true)]
force_invalid: bool,
#[arg(long, global = true)]
force_protected: bool,
}
#[derive(Clone, Debug, Args)]
#[command(next_help_heading = "Output")]
struct OutputArgs {
#[arg(
long,
global = true,
value_enum,
default_value_t,
value_name = "FORMAT"
)]
format: OutputFormat,
#[arg(long, global = true, value_enum, default_value_t, value_name = "WHEN")]
color: ColorChoice,
#[arg(long, global = true, value_enum, default_value_t, value_name = "WHEN")]
hyperlinks: AutoChoice,
#[arg(long, global = true)]
no_preview: bool,
#[arg(long, global = true)]
explain: bool,
#[arg(long, global = true, value_enum, default_value_t, value_name = "WHEN")]
progress: AutoChoice,
#[arg(short, long, global = true, conflicts_with = "verbose")]
quiet: bool,
#[arg(short, long, global = true)]
verbose: bool,
}
impl CommonArgs {
fn language(&self) -> Option<Language> {
self.policy.language.map(Language::from)
}
fn dialect(&self) -> Option<Dialect> {
self.policy.dialect.map(Dialect::from)
}
fn verbosity(&self) -> Verbosity {
match (self.output.quiet, self.output.verbose) {
(true, _) => Verbosity::Quiet,
(_, true) => Verbosity::Verbose,
_ => Verbosity::Normal,
}
}
}
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
enum ColorChoice {
#[default]
Auto,
Always,
Never,
}
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
enum AutoChoice {
#[default]
Auto,
Always,
Never,
}
#[derive(Subcommand)]
enum Command {
Check(TargetArgs),
Fix(FixArgs),
Diff(TargetArgs),
Scan(TargetArgs),
Strip,
Lsp,
Init(InitArgs),
Config(ConfigArgs),
Languages,
Plugin(PluginArgs),
Completions {
shell: Shell,
},
Doctor,
Man,
}
#[derive(Clone, Debug, Default, Args)]
struct TargetArgs {
#[arg(value_name = "PATH")]
paths: Vec<PathBuf>,
#[command(flatten)]
git: GitArgs,
}
#[derive(Clone, Debug, Default, Args)]
struct GitArgs {
#[arg(long)]
staged: bool,
#[arg(long, requires = "staged")]
index_only: bool,
}
#[derive(Args)]
struct FixArgs {
#[arg(value_name = "PATH")]
paths: Vec<PathBuf>,
#[command(flatten)]
git: GitArgs,
#[arg(long)]
dry_run: bool,
#[arg(short = 'i', long, conflicts_with_all = ["staged", "dry_run", "quiet"])]
interactive: bool,
}
impl FixArgs {
fn target(self) -> TargetArgs {
TargetArgs {
paths: self.paths,
git: self.git,
}
}
}
#[derive(Args)]
struct InitArgs {
#[arg(value_enum, default_value_t)]
kind: InitKind,
#[arg(long)]
fix: bool,
#[arg(long, conflicts_with = "stdout")]
force: bool,
#[arg(long)]
stdout: bool,
}
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
enum InitKind {
#[default]
Config,
Lefthook,
}
#[derive(Args)]
struct ConfigArgs {
#[arg(value_enum, default_value_t)]
action: ConfigAction,
}
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
enum ConfigAction {
#[default]
Show,
Locate,
Explain,
Schema,
}
#[derive(Args)]
struct PluginArgs {
#[command(subcommand)]
command: PluginCommand,
}
#[derive(Subcommand)]
enum PluginCommand {
Add {
source: String,
#[arg(long, value_name = "NAME")]
name: Option<String>,
#[arg(long, value_name = "HEX")]
sha256: Option<String>,
#[arg(long, value_name = "IDENTITY")]
identity: Option<String>,
},
Remove {
name: String,
},
List,
Update {
name: Option<String>,
},
Verify {
name: Option<String>,
},
New {
path: PathBuf,
},
}
#[derive(Clone, Copy, Default)]
struct RunFlags {
dry_run: bool,
interactive: bool,
}
impl RunFlags {
const NONE: Self = Self {
dry_run: false,
interactive: false,
};
const DRY_RUN: Self = Self {
dry_run: true,
interactive: false,
};
const INTERACTIVE: Self = Self {
dry_run: false,
interactive: true,
};
}
pub fn run() -> Result<u8> {
let cli = Cli::parse();
let common = cli.common;
if common.output.explain && common.output.format != OutputFormat::Human {
bail!("--explain is only available with --format human");
}
if common.output.explain
&& !matches!(
cli.command,
None | Some(Command::Check(_) | Command::Scan(_))
)
{
bail!("--explain is only available with `check` and `scan`");
}
match cli.command {
None => run_target(
Operation::Check,
TargetArgs {
paths: cli.paths,
..Default::default()
},
&common,
RunFlags::NONE,
),
Some(Command::Check(args)) => run_target(Operation::Check, args, &common, RunFlags::NONE),
Some(Command::Fix(args)) if args.dry_run => {
run_target(Operation::Diff, args.target(), &common, RunFlags::DRY_RUN)
}
Some(Command::Fix(args)) if args.interactive => {
if common.output.format != OutputFormat::Human {
bail!("--interactive is only available with --format human");
}
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
bail!("--interactive needs a terminal; run without -i or use `ocomment diff`");
}
run_target(
Operation::Fix,
args.target(),
&common,
RunFlags::INTERACTIVE,
)
}
Some(Command::Fix(args)) => {
run_target(Operation::Fix, args.target(), &common, RunFlags::NONE)
}
Some(Command::Diff(args)) => run_target(Operation::Diff, args, &common, RunFlags::NONE),
Some(Command::Scan(args)) => run_target(Operation::Scan, args, &common, RunFlags::NONE),
Some(Command::Strip) => run_strip(&common),
Some(Command::Lsp) => lsp::run(common.config.as_deref()),
Some(Command::Init(args)) => run_init(args),
Some(Command::Config(args)) => run_config(args, &common),
Some(Command::Languages) => print_languages(&common),
Some(Command::Plugin(args)) => run_plugin(args, &common),
Some(Command::Completions { shell }) => run_completions(shell),
Some(Command::Doctor) => run_doctor(&common),
Some(Command::Man) => run_man(),
}
}
fn run_target(
operation: Operation,
args: TargetArgs,
common: &CommonArgs,
flags: RunFlags,
) -> Result<u8> {
let mut resolved = config::load(common.config.as_deref())?;
apply_cli_overrides(&mut resolved, common);
let plugin_host = plugin::PluginHost::load(&resolved.root, &resolved.config.plugins)?;
let presentation = presentation(common);
let verbosity = common.verbosity();
if verbosity == Verbosity::Verbose && common.output.format == OutputFormat::Human {
trace_run(&resolved, &args.paths)?;
}
let progress = progress_enabled(common);
let staged = args.git.staged || resolved.config.git.staged;
if operation == Operation::Fix && !staged && args.paths.is_empty() {
note_fix_scope(&resolved, common)?;
}
if staged && let Some(repository) = config::locate_repository(&resolved.cwd) {
resolved.cwd = repository;
}
let rewrites = operation == Operation::Fix || flags.dry_run;
let (paths, stdin) = target_paths(&args.paths, rewrites, staged)?;
if staged {
if common.output.explain {
bail!(
"--explain is not available with --staged; explain the working tree with \
`ocomment check --explain`"
);
}
return git::run_staged(git::StagedRequest {
operation,
paths: &paths,
resolved: &resolved,
format: common.output.format,
index_only: args.git.index_only || resolved.config.git.index_only,
plugin_host: &plugin_host,
forced_language: common.language(),
forced_dialect: common.dialect(),
presentation,
verbosity,
preview: !common.output.no_preview,
dry_run: flags.dry_run,
});
}
let discovery = read_targets(&paths, stdin, &resolved, common)?;
let total = discovery.files.len();
let counter = Progress::default();
let explain = common.output.explain;
let materialize_output = operation == Operation::Fix
|| flags.interactive
|| (operation == Operation::Diff && common.output.format == OutputFormat::Human);
let materialize_source_map = matches!(
common.output.format,
OutputFormat::Json | OutputFormat::Jsonl
);
let needs_plan =
materialize_output || materialize_source_map || common.output.format == OutputFormat::Sarif;
let mut scanners = HashMap::new();
for file in &discovery.files {
if !scanners.contains_key(&file.options.scan) {
let scanner = PreparedScanner::new(file.options.scan.clone())
.context("cannot prepare comment policy")?;
scanners.insert(file.options.scan.clone(), Arc::new(scanner));
}
}
let processed = discovery
.files
.into_par_iter()
.map(|file| {
let trace = if explain {
let (traced_language, traced_options, trace) =
resolved.for_path_traced(&file.path, file.language, file.dialect)?;
debug_assert_eq!(traced_language, file.language);
debug_assert_eq!(traced_options, file.options);
Some(trace)
} else {
None
};
let language = file.language;
let options = file.options;
let scanner = scanners
.get(&options.scan)
.expect("every discovered policy was prepared");
let material = trace.map(|trace| FileExplanation {
options: options.scan.clone(),
trace,
});
let language_name = || {
file.path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_ascii_lowercase()
};
let result = if needs_plan {
let plan = if let Some(name) = &file.plugin {
plugin_host.transform_plan(
name,
&file.source,
&language_name(),
&file.path,
&options,
scanner,
)?
} else if let Some(profile) = &file.profile {
scanner
.transform_profile_plan(&file.source, profile, options.layout)
.expect("profiles were validated while loading configuration")
} else {
scanner.transform_plan(&file.source, language, options.layout)
};
ProcessedResult::plan(
&file.source,
plan,
materialize_output,
materialize_source_map,
)
} else {
let report = if let Some(name) = &file.plugin {
plugin_host.scan_report(
name,
&file.source,
&language_name(),
&file.path,
&options,
scanner,
)?
} else if let Some(profile) = &file.profile {
scanner
.scan_profile(&file.source, profile)
.expect("profiles were validated while loading configuration")
} else {
scanner.scan(&file.source, language)
};
let changed = (report.valid || scanner.options().force_invalid)
&& report
.comments
.iter()
.any(|comment| comment.disposition.is_remove());
ProcessedResult::report(report, changed)
};
if progress {
counter.report(total);
}
Ok::<_, anyhow::Error>((
ProcessedFile {
path: file.path,
source: file.source,
language,
result,
},
material,
))
})
.collect::<Result<Vec<_>>>();
if progress {
counter.clear();
}
let processed = processed?;
let mut explanations = Explanations::new();
let mut files = Vec::with_capacity(processed.len());
for (file, material) in processed {
if let Some(material) = material {
explanations.insert(file.path.clone(), material);
}
files.push(file);
}
let report_invalid = output::invalid(&files);
let io_invalid = discovery.skipped.iter().any(|item| item.error);
let invalid = report_invalid || io_invalid;
let may_fix = !io_invalid && (!report_invalid || resolved.config.policy.force_invalid);
if flags.interactive && may_fix {
return run_interactive(&files, &discovery.skipped, invalid, presentation, verbosity);
}
let applied = operation == Operation::Fix && may_fix;
if applied {
let plans = files
.iter()
.filter(|file| file.result.changed())
.map(|file| WritePlan {
path: file.path.clone(),
original: Cow::Borrowed(&file.source),
replacement: Cow::Borrowed(file.result.output()),
})
.collect();
apply_transaction(plans)?;
}
output::render_explained(
&files,
&discovery.skipped,
&RenderOptions {
format: common.output.format,
operation,
presentation,
verbosity,
preview: !common.output.no_preview,
explain,
dry_run: flags.dry_run,
force_invalid: resolved.config.policy.force_invalid,
applied,
policy: resolved.config.policy.mode,
},
&explanations,
)?;
if invalid {
return Ok(2);
}
match operation {
Operation::Check | Operation::Diff if output::changed(&files) => Ok(1),
_ => Ok(0),
}
}
fn run_interactive(
files: &[ProcessedFile],
skipped: &[files::SkippedFile],
invalid: bool,
presentation: Presentation,
verbosity: Verbosity,
) -> Result<u8> {
let offered: usize = files.iter().map(|file| file.result.edits.len()).sum();
let selection = {
let stdin = io::stdin();
let mut answers = stdin.lock();
let mut questions = output::stdout();
let selection = interactive::select(files, &mut answers, &mut questions, &presentation)?;
output::finish(&mut questions)?;
selection
};
let outcome = output::InteractiveOutcome {
removed: selection.accepted,
reviewed: selection.accepted + selection.declined,
offered,
changed: selection.plans.len(),
scanned: files.len(),
};
let aborted = selection.aborted;
if !aborted {
apply_transaction(selection.plans)?;
}
let stderr = io::stderr();
let mut report = stderr.lock();
if aborted {
output::note(&mut report, "Aborted; nothing was written.")?;
return Ok(0);
}
for line in output::skip_lines(skipped, presentation, verbosity) {
output::note(&mut report, &line)?;
}
output::note(&mut report, &output::interactive_summary(outcome))?;
if invalid { Ok(2) } else { Ok(0) }
}
const STDIN_ARGUMENT: &str = "-";
fn target_paths(paths: &[PathBuf], rewrites: bool, staged: bool) -> Result<(Vec<PathBuf>, bool)> {
let is_stdin = |path: &PathBuf| path.as_os_str() == STDIN_ARGUMENT;
match paths.iter().filter(|path| is_stdin(path)).count() {
0 => return Ok((paths.to_vec(), false)),
1 => {}
_ => bail!("cannot read standard input twice; `-` may appear only once"),
}
if rewrites {
bail!("cannot rewrite standard input in place; use `ocomment strip`");
}
if staged {
bail!("cannot read standard input with --staged; the index is the source");
}
Ok((
paths
.iter()
.filter(|path| !is_stdin(path))
.cloned()
.collect(),
true,
))
}
fn read_targets(
paths: &[PathBuf],
stdin: bool,
resolved: &config::ResolvedConfig,
common: &CommonArgs,
) -> Result<files::Discovery> {
if !stdin {
return files::discover(paths, resolved, common.language(), common.dialect());
}
let mut discovery = if paths.is_empty() {
files::Discovery::default()
} else {
files::discover(paths, resolved, common.language(), common.dialect())?
};
let mut bytes = Vec::new();
io::stdin()
.lock()
.read_to_end(&mut bytes)
.context("cannot read standard input")?;
match files::stdin_source(bytes, resolved, common.language(), common.dialect()) {
Ok(file) => discovery.files.push(file),
Err(skipped) if skipped.error => {
let reason = skipped.reason;
bail!("{reason}")
}
Err(skipped) => discovery.skipped.push(skipped),
}
discovery
.files
.sort_by(|left, right| left.path.cmp(&right.path));
discovery
.skipped
.sort_by(|left, right| left.path.cmp(&right.path));
Ok(discovery)
}
fn run_strip(common: &CommonArgs) -> Result<u8> {
ensure!(
common.output.format == OutputFormat::Human,
"`ocomment strip` is only available with --format human"
);
let mut source = Vec::new();
io::stdin()
.lock()
.read_to_end(&mut source)
.context("cannot read standard input")?;
let mut resolved = config::load(common.config.as_deref())?;
apply_cli_overrides(&mut resolved, common);
let detection = common
.language()
.map(|language| (language, common.dialect().unwrap_or(Dialect::Standard)))
.or_else(|| {
ocomment_core::detect_language(None, &source)
.map(|value| (value.language, value.dialect))
})
.context(files::STDIN_LANGUAGE_HELP)?;
let (language, options) = resolved.for_path(
std::path::Path::new(files::STDIN_PATH),
detection.0,
detection.1,
)?;
let result = transform(&source, language, options);
let stderr = io::stderr();
let mut report = stderr.lock();
for diagnostic in &result.report.diagnostics {
output::note(
&mut report,
&format!(
"stdin:{}..{}: {}: {}",
diagnostic.span.start,
diagnostic.span.end,
output::sanitize_message(&diagnostic.code),
output::sanitize_message(&diagnostic.message)
),
)?;
}
if !result.report.valid && !resolved.config.policy.force_invalid {
return Ok(2);
}
let mut stdout = output::stdout();
output::wrote(stdout.write_all(&result.output))?;
output::finish(&mut stdout)?;
Ok(if result.report.valid { 0 } else { 2 })
}
fn apply_cli_overrides(resolved: &mut config::ResolvedConfig, common: &CommonArgs) {
let policy = &common.policy;
let config = &mut resolved.config;
let overrides = &mut resolved.cli_overrides;
overrides.language = common.language();
overrides.dialect = common.dialect();
if let Some(value) = policy.policy {
config.policy.mode = *value;
overrides.policy = true;
}
if let Some(value) = policy.layout {
config.policy.layout = *value;
overrides.layout = true;
}
if !policy.keep_kind.is_empty() {
overrides.keep_kind_from = Some(config.policy.keep_kind.len());
config
.policy
.keep_kind
.extend(policy.keep_kind.iter().copied().map(CommentKind::from));
}
if !policy.remove_kind.is_empty() {
overrides.remove_kind_from = Some(config.policy.remove_kind.len());
config
.policy
.remove_kind
.extend(policy.remove_kind.iter().copied().map(CommentKind::from));
}
if policy.force_invalid {
config.policy.force_invalid = true;
}
if policy.force_protected {
config.policy.force_protected = true;
}
}
const ROFF_PREAMBLE: &str = concat!(r".ie \n(.g .ds Aq \(aq", "\n", r".el .ds Aq '", "\n");
fn append_fragment(page: &mut String, fragment: &[u8]) -> Result<()> {
let text = std::str::from_utf8(fragment).context("the manual page is not valid UTF-8")?;
page.push_str(text.strip_prefix(ROFF_PREAMBLE).unwrap_or(text));
Ok(())
}
fn command_options(command: &clap::Command, path: &str, page: &mut String) -> Result<()> {
for subcommand in command.get_subcommands() {
if subcommand.is_hide_set() || subcommand.get_name() == "help" {
continue;
}
let name = format!("{path} {}", subcommand.get_name());
let mut own = subcommand.clone();
let inherited: Vec<clap::Id> = own
.get_arguments()
.filter(|argument| {
argument.is_global_set()
|| argument.get_id() == "help"
|| argument.get_id() == "version"
})
.map(|argument| argument.get_id().clone())
.collect();
for id in inherited {
own = own.mut_arg(id, |argument| argument.hide(true));
}
let mut fragment = Vec::new();
clap_mangen::Man::new(own)
.render_options_section(&mut fragment)
.context("cannot render the manual page")?;
let mut rendered = String::new();
append_fragment(&mut rendered, &fragment)?;
if let Some(body) = rendered.strip_prefix(".SH OPTIONS\n")
&& !body.is_empty()
{
page.push_str(&format!(".SS {}\n{body}", name.replace('-', "\\-")));
}
command_options(subcommand, &name, page)?;
}
Ok(())
}
fn run_man() -> Result<u8> {
let man = clap_mangen::Man::new(Cli::command().after_long_help(None))
.title("OCOMMENT")
.manual("User Commands");
let mut page = String::new();
let mut fragment = Vec::new();
man.render_title(&mut fragment)
.context("cannot render the manual page")?;
page.push_str(std::str::from_utf8(&fragment).context("the manual page is not valid UTF-8")?);
type Section = fn(&clap_mangen::Man, &mut dyn Write) -> io::Result<()>;
for section in [
clap_mangen::Man::render_name_section as Section,
clap_mangen::Man::render_synopsis_section,
clap_mangen::Man::render_description_section,
clap_mangen::Man::render_options_section,
clap_mangen::Man::render_subcommands_section,
] {
fragment.clear();
section(&man, &mut fragment).context("cannot render the manual page")?;
append_fragment(&mut page, &fragment)?;
}
let mut per_command = String::new();
let mut root = Cli::command();
root.build();
command_options(&root, "ocomment", &mut per_command)?;
if !per_command.is_empty() {
page.push_str(".SH COMMAND OPTIONS\n");
page.push_str(&per_command);
}
fragment.clear();
man.render_version_section(&mut fragment)
.context("cannot render the manual page")?;
append_fragment(&mut page, &fragment)?;
if !page.ends_with('\n') {
page.push('\n');
}
page.push_str(MAN_SECTIONS);
let mut stdout = output::stdout();
output::wrote(stdout.write_all(page.as_bytes()))?;
output::finish(&mut stdout)?;
Ok(0)
}
fn run_completions(shell: Shell) -> Result<u8> {
let mut script = Vec::new();
generate(shell, &mut Cli::command(), "ocomment", &mut script);
let mut stdout = output::stdout();
output::wrote(stdout.write_all(&script))?;
output::finish(&mut stdout)?;
Ok(0)
}
fn run_init(args: InitArgs) -> Result<u8> {
let (path, contents, next_step) = match args.kind {
InitKind::Config => (
config::CONFIG_FILE,
include_str!("../assets/default-config.toml").to_owned(),
"edit [policy] and run `ocomment check`",
),
InitKind::Lefthook => {
let command = if args.fix {
"ocomment fix --staged"
} else {
"ocomment check --staged"
};
(
"lefthook.yml",
format!("pre-commit:\n commands:\n ocomment:\n run: {command}\n"),
"run `lefthook install` to activate the hook",
)
}
};
let mut stdout = output::stdout();
if args.stdout {
output::wrote(write!(stdout, "{contents}"))?;
output::finish(&mut stdout)?;
return Ok(0);
}
write_template(&mut stdout, path, &contents, args.force, next_step)?;
output::finish(&mut stdout)?;
note_inherited_config()?;
Ok(0)
}
fn note_inherited_config() -> Result<()> {
let Ok(directory) = std::env::current_dir() else {
return Ok(());
};
let Some(inherited) = directory.parent().and_then(config::locate_project) else {
return Ok(());
};
let stderr = io::stderr();
let mut report = stderr.lock();
output::note(
&mut report,
&format!(
"note: {} already applies to this directory",
output::sanitize_path(&inherited.display().to_string())
),
)
}
fn write_template(
output: &mut impl Write,
path: &str,
contents: &str,
force: bool,
next_step: &str,
) -> Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true);
if force {
options.create(true).truncate(true);
} else {
options.create_new(true);
}
let mut file = options.open(path).map_err(|error| {
if error.kind() == io::ErrorKind::AlreadyExists {
anyhow::anyhow!(
"{path} already exists; use --force to overwrite or --stdout to print the template"
)
} else {
anyhow::Error::new(error).context(format!("cannot write {path}"))
}
})?;
file.write_all(contents.as_bytes())
.with_context(|| format!("cannot write {path}"))?;
output::wrote(writeln!(output, "created {path} — {next_step}"))?;
Ok(())
}
fn run_config(args: ConfigArgs, common: &CommonArgs) -> Result<u8> {
ensure!(
common.output.format == OutputFormat::Human,
"`ocomment config` is only available with --format human"
);
let mut stdout = output::stdout();
match args.action {
ConfigAction::Schema => {
output::wrote(write!(
stdout,
"{}",
include_str!("../assets/config.schema.json")
))?;
}
action => {
let mut resolved = config::load(common.config.as_deref())?;
apply_cli_overrides(&mut resolved, common);
match action {
ConfigAction::Show => {
resolved.config.version = Some(1);
output::wrote(write!(
stdout,
"{}",
toml::to_string_pretty(&resolved.config)?
))?;
}
ConfigAction::Locate => {
if let Some(path) = &resolved.trace.user {
output::wrote(writeln!(
stdout,
"user\t{}",
output::sanitize_path(&path.display().to_string())
))?;
}
if let Some(path) = &resolved.trace.project {
output::wrote(writeln!(
stdout,
"project\t{}",
output::sanitize_path(&path.display().to_string())
))?;
}
if let Some(path) = &resolved.trace.explicit {
output::wrote(writeln!(
stdout,
"explicit\t{}",
output::sanitize_path(&path.display().to_string())
))?;
}
if resolved.trace.user.is_none()
&& resolved.trace.project.is_none()
&& resolved.trace.explicit.is_none()
{
output::wrote(writeln!(stdout, "built-in defaults"))?;
}
}
ConfigAction::Explain => {
output::wrote(writeln!(
stdout,
"precedence: built-in < XDG user < project < path override < CLI"
))?;
output::wrote(writeln!(stdout, "root: {}", root_row(&resolved)))?;
output::wrote(writeln!(
stdout,
"policy: {}; layout: {}",
resolved.config.policy.mode, resolved.config.policy.layout
))?;
}
ConfigAction::Schema => unreachable!(),
}
}
}
output::finish(&mut stdout)?;
Ok(0)
}
const LANGUAGE_TABLE: &str = include_str!("../assets/languages.toml");
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct LanguageRow {
name: String,
editor_ids: Vec<String>,
extensions: Vec<String>,
dialects: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
extension_dialects: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
reserved_names: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
shebangs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
notes: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LanguageTable {
version: u32,
languages: Vec<LanguageRow>,
}
fn language_table() -> Result<Vec<LanguageRow>> {
let table: LanguageTable = toml::from_str(LANGUAGE_TABLE)
.context("the embedded spec/languages.toml is not a language table")?;
ensure!(
table.version == 1,
"the embedded spec/languages.toml is version {}, which this build does not know",
table.version
);
Ok(table.languages)
}
fn print_languages(common: &CommonArgs) -> Result<u8> {
let rows = language_table()?;
let mut stdout = output::stdout();
match common.output.format {
OutputFormat::Human => {
output::wrote(writeln!(stdout, "language\textensions\tdialects\tnotes"))?;
for row in &rows {
let extensions = row.extensions.join(",");
let dialects = row.dialects.join(",");
let line = match &row.notes {
Some(notes) => format!("{}\t{extensions}\t{dialects}\t{notes}", row.name),
None => format!("{}\t{extensions}\t{dialects}", row.name),
};
output::wrote(writeln!(stdout, "{line}"))?;
}
}
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&rows)
.context("cannot render the language table as JSON")?;
output::wrote(writeln!(stdout, "{json}"))?;
}
_ => bail!("`ocomment languages` is only available with --format human or --format json"),
}
output::finish(&mut stdout)?;
Ok(0)
}
fn run_plugin(args: PluginArgs, common: &CommonArgs) -> Result<u8> {
let resolved = config::load(common.config.as_deref())?;
let mut stdout = output::stdout();
match args.command {
PluginCommand::Add {
source,
name,
sha256,
identity,
} => plugin::add(
&mut stdout,
&resolved.root,
&source,
name.as_deref(),
sha256.as_deref(),
identity.as_deref(),
)?,
PluginCommand::Remove { name } => plugin::remove(&mut stdout, &resolved.root, &name)?,
PluginCommand::List => plugin::list(&mut stdout, &resolved.root)?,
PluginCommand::Update { name } => {
plugin::update(&mut stdout, &resolved.root, name.as_deref())?;
}
PluginCommand::Verify { name } => {
plugin::verify(&mut stdout, &resolved.root, name.as_deref())?;
}
PluginCommand::New { path } => plugin::new_plugin(&mut stdout, &path)?,
}
output::finish(&mut stdout)?;
Ok(0)
}
const STAGED_READS: &str = "--staged";
const PROBED_TOOLS: [(&str, &[&str], &str); 5] = [
("git", &["--version"], STAGED_READS),
("curl", &["--version"], plugin::HTTPS_SOURCES),
("gh", &["--version"], plugin::GH_SOURCES),
("oras", &["version"], plugin::OCI_SOURCES),
("cosign", &["version"], plugin::SIGNATURE_VERIFICATION),
];
enum Probe {
Found(String),
Missing,
Failed(String),
}
fn probe(tool: &str, args: &[&str]) -> Probe {
let output = match std::process::Command::new(tool).args(args).output() {
Ok(output) => output,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Probe::Missing,
Err(error) => return Probe::Failed(error.to_string()),
};
let version = version_line(&output.stdout).or_else(|| version_line(&output.stderr));
match (output.status.success(), version) {
(true, Some(line)) => Probe::Found(line),
(true, None) => Probe::Failed("ran but said nothing about itself".to_owned()),
(false, Some(line)) => Probe::Failed(line),
(false, None) => Probe::Failed(output.status.to_string()),
}
}
fn version_line(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
let mut fallback = None;
for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) {
if line.chars().any(|character| character.is_ascii_digit()) {
return Some(output::sanitize_line(line));
}
fallback.get_or_insert_with(|| output::sanitize_line(line));
}
fallback
}
fn run_doctor(common: &CommonArgs) -> Result<u8> {
let stdout_tty = io::stdout().is_terminal();
let mut stdout = output::stdout();
output::wrote(writeln!(stdout, "ocomment {}", env!("CARGO_PKG_VERSION")))?;
match std::env::current_dir() {
Ok(directory) => output::wrote(writeln!(
stdout,
"cwd: {}",
output::sanitize_path(&directory.to_string_lossy())
))?,
Err(error) => output::wrote(writeln!(stdout, "cwd: unavailable ({error})"))?,
}
let resolved = config::load(common.config.as_deref())?;
output::wrote(writeln!(stdout, "root: {}", root_row(&resolved)))?;
for source in config_trace(&resolved.trace) {
output::wrote(writeln!(stdout, "config: {source}"))?;
}
output::wrote(writeln!(stdout, "configuration: ok"))?;
output::wrote(writeln!(
stdout,
"languages: {} built in",
Language::ALL.len()
))?;
output::wrote(writeln!(
stdout,
"stdout: {}",
if stdout_tty {
"a terminal"
} else {
"not a terminal"
}
))?;
output::wrote(writeln!(
stdout,
"NO_COLOR: {}",
if std::env::var_os("NO_COLOR").is_some() {
"set"
} else {
"unset"
}
))?;
for (tool, arguments, purpose) in PROBED_TOOLS {
let row = match probe(tool, arguments) {
Probe::Found(version) => format!("{tool}: {version}"),
Probe::Missing => format!("{tool}: not found (needed for {purpose})"),
Probe::Failed(reason) => format!("{tool}: failed (needed for {purpose}): {reason}"),
};
output::wrote(writeln!(stdout, "{row}"))?;
}
plugin::verify(&mut stdout, &resolved.root, None)?;
output::wrote(writeln!(
stdout,
"LSP: stdio server available; on-save is opt-in ({})",
resolved.config.lsp.on_save
))?;
output::finish(&mut stdout)?;
Ok(0)
}
const PROGRESS_STEP: usize = 50;
fn progress_enabled(common: &CommonArgs) -> bool {
common.output.format == OutputFormat::Human
&& common.verbosity() != Verbosity::Quiet
&& match common.output.progress {
AutoChoice::Auto => io::stderr().is_terminal(),
AutoChoice::Always => true,
AutoChoice::Never => false,
}
}
#[derive(Default)]
struct Progress {
scanned: AtomicUsize,
drawn: AtomicBool,
}
impl Progress {
fn report(&self, total: usize) {
let seen = self.scanned.fetch_add(1, Ordering::Relaxed) + 1;
if !seen.is_multiple_of(PROGRESS_STEP) && seen != total {
return;
}
let mut stderr = io::stderr().lock();
let _ = write!(stderr, "\rocomment: scanning {seen}/{total} files");
let _ = stderr.flush();
self.drawn.store(true, Ordering::Relaxed);
}
fn clear(&self) {
if !self.drawn.load(Ordering::Relaxed) {
return;
}
let mut stderr = io::stderr().lock();
let _ = write!(stderr, "\r\x1b[2K");
let _ = stderr.flush();
}
}
fn presentation(common: &CommonArgs) -> Presentation {
let stdout_tty = io::stdout().is_terminal();
let no_color = std::env::var_os("NO_COLOR").is_some();
Presentation {
color: !no_color
&& match common.output.color {
ColorChoice::Auto => stdout_tty,
ColorChoice::Always => true,
ColorChoice::Never => false,
},
hyperlinks: match common.output.hyperlinks {
AutoChoice::Auto => stdout_tty,
AutoChoice::Always => true,
AutoChoice::Never => false,
},
}
}
fn root_row(resolved: &config::ResolvedConfig) -> String {
output::sanitize_path(&resolved.root.to_string_lossy())
}
fn target_label(paths: &[PathBuf]) -> String {
if paths.is_empty() {
return files::DEFAULT_TARGET.to_owned();
}
paths
.iter()
.map(|path| output::sanitize_path(&path.display().to_string()))
.collect::<Vec<_>>()
.join(" ")
}
fn note_fix_scope(resolved: &config::ResolvedConfig, common: &CommonArgs) -> Result<()> {
if resolved.cwd == resolved.root
|| common.output.format != OutputFormat::Human
|| common.verbosity() == Verbosity::Quiet
{
return Ok(());
}
let stderr = io::stderr();
let mut report = stderr.lock();
output::note(
&mut report,
&format!(
"note: fixing files under {} (project root: {})",
files::DEFAULT_TARGET,
root_row(resolved)
),
)
}
fn trace_run(resolved: &config::ResolvedConfig, paths: &[PathBuf]) -> Result<()> {
let stderr = io::stderr();
let mut report = stderr.lock();
output::note(&mut report, &format!("root: {}", root_row(resolved)))?;
output::note(&mut report, &format!("target: {}", target_label(paths)))?;
for source in config_trace(&resolved.trace) {
output::note(&mut report, &format!("config: {source}"))?;
}
Ok(())
}
fn config_trace(trace: &config::ConfigTrace) -> Vec<String> {
let sources: Vec<String> = [
("user", &trace.user),
("project", &trace.project),
("explicit", &trace.explicit),
]
.into_iter()
.filter_map(|(label, path)| {
path.as_ref()
.map(|path| format!("{label} {}", output::sanitize_path(&path.to_string_lossy())))
})
.collect();
if sources.is_empty() {
return vec!["built-in defaults".to_owned()];
}
sources
}