use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use alint_core::{Engine, RuleRegistry, WalkOptions, walk};
use alint_output::{ColorChoice, Format, GlyphSet, HumanOptions};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
mod export_agents_md;
mod init;
mod progress;
mod rules;
mod suggest;
const ALINT_LONG_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("ALINT_GIT_SHA"),
", built ",
env!("ALINT_BUILD_DATE"),
")",
);
#[derive(Parser, Debug)]
#[command(
name = "alint",
version,
long_version = ALINT_LONG_VERSION,
about = "Language-agnostic linter for repository structure, existence, naming, and content rules",
long_about = None,
)]
#[allow(clippy::struct_excessive_bools)]
struct Cli {
#[arg(long, short = 'c', global = true)]
config: Vec<PathBuf>,
#[arg(long, short = 'f', global = true, default_value = "human")]
format: String,
#[arg(long, global = true)]
no_gitignore: bool,
#[arg(long, global = true)]
fail_on_warning: bool,
#[arg(long, global = true)]
show_notes: bool,
#[arg(
long,
global = true,
value_name = "WHEN",
default_value = "auto",
value_parser = clap::builder::PossibleValuesParser::new(["auto", "always", "never"]),
)]
color: String,
#[arg(long, global = true)]
ascii: bool,
#[arg(long, global = true)]
compact: bool,
#[arg(long, global = true, value_name = "COLS")]
width: Option<usize>,
#[arg(long, global = true)]
no_docs: bool,
#[arg(
long,
global = true,
value_name = "WHEN",
default_value = "auto",
value_parser = clap::builder::PossibleValuesParser::new(["auto", "always", "never"]),
)]
progress: String,
#[arg(long, short = 'q', global = true)]
quiet: bool,
#[arg(long, global = true, value_name = "FILE")]
baseline: Option<PathBuf>,
#[arg(long, global = true)]
strict_baseline: bool,
#[arg(long, global = true)]
show_baselined: bool,
#[arg(long, global = true, value_name = "RULE_ID")]
only: Vec<String>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Debug)]
enum Command {
Check {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
changed: bool,
#[arg(long, value_name = "REF")]
base: Option<String>,
},
List {
#[arg(long)]
category: Option<String>,
},
Explain {
rule_id: String,
},
Fix {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
dry_run: bool,
#[arg(long)]
changed: bool,
#[arg(long, value_name = "REF")]
base: Option<String>,
},
Baseline {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long)]
accept_new: bool,
},
Facts {
#[arg(default_value = ".")]
path: PathBuf,
},
Init {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
monorepo: bool,
},
ExportAgentsMd {
#[arg(long, value_name = "PATH")]
output: Option<PathBuf>,
#[arg(long, requires = "output")]
inline: bool,
#[arg(long, value_name = "TEXT")]
section_title: Option<String>,
#[arg(long)]
include_info: bool,
#[arg(
long,
short = 'f',
value_name = "FORMAT",
default_value = "markdown",
value_parser = clap::builder::PossibleValuesParser::new(["markdown", "json"]),
)]
format: String,
},
Suggest {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(
long,
short = 'f',
value_name = "FORMAT",
default_value = "human",
value_parser = clap::builder::PossibleValuesParser::new(["human", "yaml", "json"]),
)]
format: String,
#[arg(
long,
value_name = "LEVEL",
default_value = "medium",
value_parser = clap::builder::PossibleValuesParser::new(["low", "medium", "high"]),
)]
confidence: String,
#[arg(long)]
include_bundled: bool,
#[arg(long)]
explain: bool,
},
ValidateConfig {
path: Option<PathBuf>,
#[arg(
long,
short = 'f',
value_name = "FORMAT",
default_value = "human",
value_parser = clap::builder::PossibleValuesParser::new(["human", "json"]),
)]
format: String,
},
Lsp,
Rules {
#[command(subcommand)]
command: RulesCommand,
},
}
#[derive(Subcommand, Debug)]
enum RulesCommand {
List {
#[arg(long)]
category: Option<String>,
#[arg(long)]
search: Option<String>,
},
Categories,
}
fn main() -> ExitCode {
init_panic_hook();
init_tracing();
let cli = Cli::parse();
match run(cli) {
Ok(code) => code,
Err(e) => {
eprintln!("alint: {e:#}");
if error_is_internal(&e) {
ExitCode::from(3)
} else {
ExitCode::from(2)
}
}
}
}
fn error_is_internal(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<alint_core::Error>()
.is_some_and(alint_core::Error::is_internal)
})
}
fn init_panic_hook() {
if std::env::var_os("RUST_BACKTRACE").is_some() {
return;
}
std::panic::set_hook(Box::new(|info| {
let location = info.location().map_or_else(
|| "(unknown)".to_string(),
|l| format!("{}:{}", l.file(), l.line()),
);
let payload = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
.unwrap_or("(non-string panic payload)");
let title = format!("alint panic: {payload}");
let body = format!(
"alint version: {ver}\n\
OS: {os}\n\
Panic location: {location}\n\
Panic message: {payload}\n\n\
Steps to reproduce:\n\
1. ...\n\
2. ...\n\
3. ...\n\n\
Expected behaviour:\n\n\
Actual behaviour:\n",
ver = ALINT_LONG_VERSION,
os = std::env::consts::OS,
);
let url = format!(
"https://github.com/asamarts/alint/issues/new?title={}&body={}",
url_encode(&title),
url_encode(&body),
);
eprintln!("\nalint crashed unexpectedly. This is a bug — please file a report:");
eprintln!(" {url}\n");
eprintln!("Panic: {payload}");
eprintln!("Location: {location}");
eprintln!("Re-run with `RUST_BACKTRACE=1` for the full backtrace.");
}));
}
fn url_encode(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
let c = *b;
if c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~') {
out.push(c as char);
} else {
let _ = write!(out, "%{c:02X}");
}
}
out
}
fn init_tracing() {
use tracing_subscriber::{EnvFilter, fmt};
let filter = EnvFilter::try_from_env("ALINT_LOG").unwrap_or_else(|_| EnvFilter::new("warn"));
let _ = fmt().with_env_filter(filter).with_target(false).try_init();
}
fn run(mut cli: Cli) -> Result<ExitCode> {
let command = cli.command.take().unwrap_or(Command::Check {
path: PathBuf::from("."),
changed: false,
base: None,
});
if !cli.only.is_empty() && !matches!(command, Command::Check { .. } | Command::Fix { .. }) {
bail!("`--only` only applies to `check` and `fix`");
}
if cli.config.len() > 1 {
bail!(
"`--config` may be given at most once (got {}); compose configs with an \
`extends:` chain instead of repeating `-c`",
cli.config.len()
);
}
if (cli.baseline.is_some() || cli.strict_baseline || cli.show_baselined)
&& !matches!(command, Command::Check { .. })
{
bail!(
"`--baseline`, `--strict-baseline`, and `--show-baselined` apply only to \
`check` (the `baseline` subcommand writes via `--output`)"
);
}
match command {
Command::Check {
path,
changed,
base,
} => cmd_check(&path, &ChangedMode::new(changed, base), &cli.only, &cli),
Command::List { category } => cmd_list(category.as_deref(), &cli),
Command::Explain { rule_id } => cmd_explain(&rule_id, &cli),
Command::Fix {
path,
dry_run,
changed,
base,
} => cmd_fix(
&path,
dry_run,
&ChangedMode::new(changed, base),
&cli.only,
&cli,
),
Command::Baseline {
path,
output,
accept_new,
} => cmd_baseline(&path, output.as_deref(), accept_new, &cli),
Command::Facts { path } => cmd_facts(&path, &cli),
Command::Init { path, monorepo } => {
reject_non_human_format(&cli, "init")?;
cmd_init(&path, monorepo)
}
Command::ExportAgentsMd {
output,
inline,
section_title,
include_info,
format,
} => cmd_export_agents_md(
&ExportAgentsMdOptions {
output,
inline,
section_title,
include_info,
format,
},
&cli,
),
Command::Suggest {
path,
format,
confidence,
include_bundled,
explain,
} => cmd_suggest(
&path,
&SuggestOptions {
format,
confidence,
include_bundled,
explain,
},
&cli,
),
Command::ValidateConfig { path, format } => cmd_validate_config(path, &format, &cli),
Command::Lsp => {
reject_non_human_format(&cli, "lsp")?;
cmd_lsp()
}
Command::Rules { command } => rules::run(&command, &cli),
}
}
fn reject_non_human_format(cli: &Cli, cmd: &str) -> anyhow::Result<()> {
if cli.format != "human" {
anyhow::bail!(
"`{cmd}` does not support `--format {}` — it produces no formatted report; drop `--format`",
cli.format
);
}
Ok(())
}
fn cmd_lsp() -> Result<ExitCode> {
alint_lsp::run_stdio().context("running language server")?;
Ok(ExitCode::SUCCESS)
}
#[derive(Debug)]
struct ExportAgentsMdOptions {
output: Option<PathBuf>,
inline: bool,
section_title: Option<String>,
include_info: bool,
format: String,
}
fn cmd_export_agents_md(opts: &ExportAgentsMdOptions, cli: &Cli) -> Result<ExitCode> {
use export_agents_md::{OutputFormat, RunOptions};
let format: OutputFormat = opts
.format
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let section_title = opts
.section_title
.clone()
.unwrap_or_else(|| "Lint rules enforced by alint".to_string());
let run_opts = RunOptions {
format,
output: opts.output.clone(),
inline: opts.inline,
section_title,
include_info: opts.include_info,
};
export_agents_md::run(cli.config.first().map(PathBuf::as_path), &run_opts)
}
#[derive(Debug)]
struct SuggestOptions {
format: String,
confidence: String,
include_bundled: bool,
explain: bool,
}
fn cmd_suggest(path: &Path, opts: &SuggestOptions, cli: &Cli) -> Result<ExitCode> {
use suggest::{Confidence, OutputFormat};
let format: OutputFormat = opts
.format
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let confidence: Confidence = opts
.confidence
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let progress_mode = if cli.quiet {
progress::ProgressMode::Never
} else {
cli.progress
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?
};
let progress = progress::Progress::new(progress_mode);
let (mut out, _opts) = render_env(cli)?;
suggest::run(
path,
&suggest::RunOptions {
format,
confidence,
include_bundled: opts.include_bundled,
explain: opts.explain,
quiet: cli.quiet,
width: cli.width,
},
&progress,
&mut out,
)
}
fn cmd_init(path: &Path, monorepo: bool) -> Result<ExitCode> {
for name in [".alint.yml", ".alint.yaml", "alint.yml", "alint.yaml"] {
let candidate = path.join(name);
if candidate.is_file() {
bail!(
"{} already exists; refusing to overwrite. Delete it first if you really \
want to regenerate, or edit it directly.",
candidate.display()
);
}
}
let detection = init::detect(path, monorepo);
let body = init::render(&detection);
let target = path.join(".alint.yml");
std::fs::write(&target, &body).with_context(|| format!("writing {}", target.display()))?;
let summary = init::render_summary(&detection);
if summary.is_empty() {
println!(
"Wrote {} — extends `oss-baseline@v1` only.",
target.display()
);
println!(
" No language manifests detected. Add an `extends:` line for your stack \
(`alint://bundled/rust@v1`, `node@v1`, …) when ready."
);
} else {
println!("Wrote {} — detected: {}.", target.display(), summary);
println!(" Run `alint check` to lint against the generated config.");
}
Ok(ExitCode::SUCCESS)
}
#[derive(Debug)]
struct ChangedMode {
enabled: bool,
base: Option<String>,
}
impl ChangedMode {
fn new(changed_flag: bool, base: Option<String>) -> Self {
let enabled = changed_flag || base.is_some();
Self { enabled, base }
}
fn resolve(&self, root: &Path) -> Result<Option<std::collections::HashSet<PathBuf>>> {
if !self.enabled {
return Ok(None);
}
let set = alint_core::git::collect_changed_paths(root, self.base.as_deref()).ok_or_else(
|| {
let what = self.base.as_deref().map_or_else(
|| "git ls-files --modified --others --exclude-standard".to_string(),
|r| format!("git diff --name-only {r}...HEAD"),
);
anyhow::anyhow!(
"--changed requires a git repository (and `git` on PATH); \
`{what}` failed at {}. Run without --changed for a full check.",
root.display()
)
},
)?;
Ok(Some(set))
}
}
fn apply_only_filter(
entries: Vec<alint_core::RuleEntry>,
only: &[String],
) -> Result<Vec<alint_core::RuleEntry>> {
if only.is_empty() {
return Ok(entries);
}
let wanted: std::collections::HashSet<&str> = only.iter().map(String::as_str).collect();
let present: std::collections::HashSet<&str> = entries.iter().map(|e| e.rule.id()).collect();
let mut missing: Vec<&str> = wanted
.iter()
.copied()
.filter(|id| !present.contains(id))
.collect();
if !missing.is_empty() {
missing.sort_unstable();
bail!(
"no rule with id {} found in the effective config (passed via --only)",
missing
.iter()
.map(|id| format!("{id:?}"))
.collect::<Vec<_>>()
.join(", ")
);
}
Ok(entries
.into_iter()
.filter(|e| wanted.contains(e.rule.id()))
.collect())
}
fn require_directory(path: &Path) -> Result<()> {
if !path.is_dir() {
bail!(
"{} is {}, but `check`/`fix`/`baseline` take a repository root (a \
directory), not a single file",
path.display(),
if path.exists() { "a file" } else { "not found" },
);
}
Ok(())
}
fn baseline_walk_exclude(root: &Path, baseline: &Path) -> Option<String> {
let root_abs = root.canonicalize().ok()?;
let base_abs = baseline.canonicalize().ok()?;
let rel = base_abs.strip_prefix(&root_abs).ok()?;
Some(format!("/{}", rel.to_string_lossy().replace('\\', "/")))
}
fn exclude_baseline_from_walk(
extra_ignores: &mut Vec<String>,
root: &Path,
baseline: Option<&Path>,
) {
if let Some(bp) = baseline
&& let Some(rel) = baseline_walk_exclude(root, bp)
{
extra_ignores.push(rel);
}
}
fn cmd_check(path: &Path, changed: &ChangedMode, only: &[String], cli: &Cli) -> Result<ExitCode> {
require_directory(path)?;
let loaded = load_rules(path, cli)?;
let config_baseline = loaded.baseline.as_ref().map(|b| path.join(b));
let effective_baseline = cli.baseline.clone().or(config_baseline);
let entries = apply_only_filter(loaded.entries, only)?;
let rule_count = entries.len();
let mut engine = Engine::from_entries(entries, loaded.registry)
.with_facts(loaded.facts)
.with_vars(loaded.vars);
let changed_active = match changed.resolve(path)? {
Some(set) => {
engine = engine.with_changed_paths(set);
true
}
None => false,
};
let effective_gitignore = if cli.no_gitignore {
false
} else {
loaded.respect_gitignore
};
let mut extra_ignores = loaded.extra_ignores;
exclude_baseline_from_walk(&mut extra_ignores, path, effective_baseline.as_deref());
let walk_opts = WalkOptions {
respect_gitignore: effective_gitignore,
extra_ignores,
};
let index = walk(path, &walk_opts).context("walking repository")?;
tracing::debug!(files = index.entries.len(), "walk complete");
let report = engine.run(path, &index).context("running rules")?;
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let mut strict_stale_fail = false;
let (report, baseline_marks) = if let Some(baseline_path) = &effective_baseline {
let baseline = load_baseline(baseline_path)?;
let mut reader = FileReader::new(path);
let applied =
alint_core::baseline::apply(&report, &baseline, |rid, v| reader.fingerprint(rid, v));
let scoped = changed_active || !only.is_empty();
report_baseline_summary(&applied, cli, !scoped);
if cli.strict_baseline && !scoped && !applied.stale.is_empty() {
strict_stale_fail = true;
}
let marks =
matches!(format, Format::Sarif | Format::Json).then(|| build_baseline_marks(&applied));
(applied.live, marks)
} else {
(report, None)
};
let (mut out, opts) = render_env(cli)?;
match (format, baseline_marks.as_ref()) {
(Format::Sarif, Some(marks)) => {
alint_output::write_sarif_with_baseline(&report, Some(marks), &mut out)
}
(Format::Json, Some(marks)) => alint_output::write_json_with_baseline(
&report,
Some(marks),
cli.show_baselined,
&mut out,
),
(Format::Gitlab, _) => {
let mut reader = FileReader::new(path);
let fps: Vec<Vec<String>> = report
.results
.iter()
.map(|r| {
r.violations
.iter()
.map(|v| reader.fingerprint(&r.rule_id, v))
.collect()
})
.collect();
alint_output::write_gitlab(&report, Some(&fps), &mut out)
}
_ => format.write_with_options(&report, &mut out, opts),
}
.context("writing output")?;
out.flush().ok();
if format == Format::Human {
report_notes_to_stderr(&report, cli.show_notes);
}
tracing::debug!(rules = rule_count, "done");
let exit = if report.has_errors()
|| (cli.fail_on_warning && report.has_warnings())
|| strict_stale_fail
{
ExitCode::from(1)
} else {
ExitCode::SUCCESS
};
Ok(exit)
}
struct FileReader<'a> {
root: &'a Path,
cache: std::collections::HashMap<PathBuf, Option<Vec<u8>>>,
}
impl<'a> FileReader<'a> {
fn new(root: &'a Path) -> Self {
Self {
root,
cache: std::collections::HashMap::new(),
}
}
fn fingerprint(&mut self, rule_id: &str, v: &alint_core::Violation) -> String {
let bytes = match v.path.as_ref() {
Some(p) => self
.cache
.entry(p.to_path_buf())
.or_insert_with(|| {
let full = self.root.join(p);
let size = std::fs::metadata(&full).map_or(0, |m| m.len());
alint_core::read_capped_or_skip(&full, size)
})
.as_deref(),
None => None,
};
alint_core::baseline::fingerprint(rule_id, v, bytes)
}
}
fn build_baseline_marks(
applied: &alint_core::baseline::AppliedBaseline,
) -> alint_output::BaselineMarks {
use std::collections::HashMap;
let mut by_rule: HashMap<&str, Vec<alint_output::SuppressedFinding>> = HashMap::new();
for s in &applied.suppressed {
by_rule
.entry(s.rule_id.as_ref())
.or_default()
.push(alint_output::SuppressedFinding {
violation: s.violation.clone(),
fingerprint: s.fingerprint.clone(),
});
}
let per_result = applied
.live
.results
.iter()
.enumerate()
.map(|(i, rr)| alint_output::ResultMarks {
live_fingerprints: applied
.live_fingerprints
.get(i)
.cloned()
.unwrap_or_default(),
suppressed: by_rule.remove(rr.rule_id.as_ref()).unwrap_or_default(),
})
.collect();
alint_output::BaselineMarks {
per_result,
suppressed_total: applied.suppressed_total,
}
}
fn load_baseline(path: &Path) -> Result<alint_core::baseline::Baseline> {
let text = std::fs::read_to_string(path).with_context(|| {
format!(
"reading baseline file {} (run `alint baseline` to create it)",
path.display()
)
})?;
alint_core::baseline::Baseline::load(&text)
.map_err(|e| anyhow::anyhow!("invalid baseline file {}: {e}", path.display()))
}
fn report_baseline_summary(
applied: &alint_core::baseline::AppliedBaseline,
cli: &Cli,
report_stale: bool,
) {
if !cli.quiet && applied.suppressed_total > 0 {
eprintln!(
"alint: {} baselined violation(s) suppressed",
applied.suppressed_total
);
}
if cli.show_baselined {
for s in &applied.suppressed {
match &s.violation.path {
Some(p) => eprintln!(
" baselined: {}: [{}] {}",
p.display(),
s.rule_id,
s.violation.message
),
None => eprintln!(" baselined: [{}] {}", s.rule_id, s.violation.message),
}
}
}
if report_stale && !cli.quiet && !applied.stale.is_empty() {
let n = applied.stale.len();
eprintln!(
"alint: {n} baseline entr{} no longer fire{}; run `alint baseline` to re-tighten{}",
if n == 1 { "y" } else { "ies" },
if n == 1 { "s" } else { "" },
if cli.strict_baseline {
" (failing build: --strict-baseline)"
} else {
""
}
);
}
}
fn cmd_baseline(
path: &Path,
output: Option<&Path>,
accept_new: bool,
cli: &Cli,
) -> Result<ExitCode> {
use alint_core::baseline::{Baseline, FingerprintedViolation};
require_directory(path)?;
if cli.format != "human" {
anyhow::bail!(
"`baseline` does not support `--format {}` — it writes the baseline file and a \
human summary; drop `--format` (use `--quiet` to silence the summary)",
cli.format
);
}
let loaded = load_rules(path, cli)?;
let out_path = output.map_or_else(
|| {
loaded
.baseline
.as_ref()
.map_or_else(|| path.join(".alint-baseline.json"), |b| path.join(b))
},
Path::to_path_buf,
);
let engine = Engine::from_entries(loaded.entries, loaded.registry)
.with_facts(loaded.facts)
.with_vars(loaded.vars);
let mut extra_ignores = loaded.extra_ignores;
exclude_baseline_from_walk(&mut extra_ignores, path, Some(&out_path));
let walk_opts = WalkOptions {
respect_gitignore: if cli.no_gitignore {
false
} else {
loaded.respect_gitignore
},
extra_ignores,
};
let index = walk(path, &walk_opts).context("walking repository")?;
let report = engine.run(path, &index).context("running rules")?;
let mut reader = FileReader::new(path);
let mut items: Vec<FingerprintedViolation> = Vec::new();
for result in &report.results {
for v in &result.violations {
items.push(FingerprintedViolation {
rule_id: result.rule_id.to_string(),
path: v
.path
.as_ref()
.map(|p| p.to_string_lossy().replace('\\', "/")),
fingerprint: reader.fingerprint(&result.rule_id, v),
message: Some(v.message.to_string()),
});
}
}
let new_baseline =
Baseline::from_fingerprints(Some(env!("CARGO_PKG_VERSION").to_string()), items);
if out_path.exists() && !accept_new {
use std::collections::HashMap;
let existing = load_baseline(&out_path)?;
let old_counts: HashMap<&str, u32> = existing
.entries
.iter()
.map(|e| (e.fingerprint.as_str(), e.count))
.collect();
let new_counts: HashMap<&str, u32> = new_baseline
.entries
.iter()
.map(|e| (e.fingerprint.as_str(), e.count))
.collect();
let added: u64 = new_counts
.iter()
.map(|(fp, &c)| u64::from(c.saturating_sub(old_counts.get(fp).copied().unwrap_or(0))))
.sum();
let removed: u64 = old_counts
.iter()
.map(|(fp, &c)| u64::from(c.saturating_sub(new_counts.get(fp).copied().unwrap_or(0))))
.sum();
if added > 0 {
bail!(
"regenerating {} would grandfather {added} new violation(s) (+{added} / -{removed}); \
fix them, or pass --accept-new to accept them into the baseline",
out_path.display()
);
}
}
std::fs::write(&out_path, new_baseline.to_jsonl())
.with_context(|| format!("writing baseline {}", out_path.display()))?;
if !cli.quiet {
let n = new_baseline.entries.len();
let total = new_baseline.total();
eprintln!(
"alint: wrote {} ({n} entr{}, {total} occurrence{})",
out_path.display(),
if n == 1 { "y" } else { "ies" },
if total == 1 { "" } else { "s" },
);
}
Ok(ExitCode::SUCCESS)
}
fn report_notes_to_stderr(report: &alint_core::Report, show_notes: bool) {
let total: usize = report.results.iter().map(|r| r.notes.len()).sum();
if total == 0 {
return;
}
if show_notes {
eprintln!("alint: {total} informational note(s):");
for result in &report.results {
for note in &result.notes {
match ¬e.path {
Some(p) => eprintln!(" note: {}: {}", p.display(), note.message),
None => eprintln!(" note: {}", note.message),
}
}
}
} else {
eprintln!("alint: {total} informational note(s); run with --show-notes to list.");
}
}
fn cmd_fix(
path: &Path,
dry_run: bool,
changed: &ChangedMode,
only: &[String],
cli: &Cli,
) -> Result<ExitCode> {
require_directory(path)?;
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
if !matches!(format, Format::Human | Format::Json | Format::Markdown) {
bail!(
"`alint fix` supports only `--format human`, `--format json`, or \
`--format markdown` (got {fmt:?}); the SARIF/GitHub/JUnit/GitLab/agent \
formats describe findings, not fixes — run `alint check --format {fmt}` \
for those",
fmt = cli.format,
);
}
let loaded = load_rules(path, cli)?;
let entries = apply_only_filter(loaded.entries, only)?;
let mut engine = Engine::from_entries(entries, loaded.registry)
.with_facts(loaded.facts)
.with_vars(loaded.vars)
.with_fix_size_limit(loaded.fix_size_limit);
if let Some(set) = changed.resolve(path)? {
engine = engine.with_changed_paths(set);
}
let effective_gitignore = if cli.no_gitignore {
false
} else {
loaded.respect_gitignore
};
let effective_baseline = cli
.baseline
.clone()
.or_else(|| loaded.baseline.as_ref().map(|b| path.join(b)));
let mut extra_ignores = loaded.extra_ignores;
exclude_baseline_from_walk(&mut extra_ignores, path, effective_baseline.as_deref());
let walk_opts = WalkOptions {
respect_gitignore: effective_gitignore,
extra_ignores,
};
let index = walk(path, &walk_opts).context("walking repository")?;
let report = engine
.fix(path, &index, dry_run)
.context("applying fixes")?;
let (mut out, opts) = render_env(cli)?;
format
.write_fix_with_options(&report, &mut out, opts)
.context("writing output")?;
out.flush().ok();
let exit = if report.has_unfixable_errors()
|| (cli.fail_on_warning && report.has_unfixable_warnings())
{
ExitCode::from(1)
} else {
ExitCode::SUCCESS
};
Ok(exit)
}
fn cmd_list(category: Option<&str>, cli: &Cli) -> Result<ExitCode> {
use alint_core::{Category, Level};
use alint_output::style;
if let Some(slug) = category
&& Category::from_slug(slug).is_none()
{
let known: Vec<&str> = Category::ALL.iter().map(|c| c.slug()).collect();
bail!(
"unknown category {slug:?}. Known categories: {}",
known.join(", ")
);
}
let mut loaded = load_rules(Path::new("."), cli)?;
let loaded_any = !loaded.entries.is_empty();
if let Some(slug) = category {
loaded
.entries
.retain(|e| rules::categories_for_kind(&e.kind).contains(&slug));
}
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
match format {
Format::Human => {}
Format::Json => return list_json(&loaded),
_ => bail!(
"`alint list` supports only `--format human` or `--format json` (got {:?})",
cli.format
),
}
let (mut out, opts) = render_env(cli)?;
if loaded.entries.is_empty() {
match category {
Some(slug) if loaded_any => {
writeln!(out, "(no loaded rules are in category '{slug}')")?;
}
_ => writeln!(out, "(no rules loaded from config)")?,
}
out.flush().ok();
return Ok(ExitCode::SUCCESS);
}
let dim = style::DIM;
let docs = style::DOCS;
for entry in &loaded.entries {
let rule = &entry.rule;
let level_style = match rule.level() {
Level::Error => style::ERROR,
Level::Warning => style::WARNING,
Level::Info => style::INFO,
Level::Off => style::DIM,
};
let label = rule.level().as_str();
let pad = " ".repeat(8usize.saturating_sub(label.len()));
write!(
out,
"{level_style}{label}{level_style:#}{pad} {}",
rule.id()
)?;
if entry.when.is_some() {
write!(out, " {dim}[when]{dim:#}")?;
}
if opts.show_docs
&& let Some(url) = rule.policy_url()
{
write!(out, " {dim}({dim:#}{docs}{url}{docs:#}{dim}){dim:#}")?;
}
writeln!(out)?;
}
out.flush().ok();
Ok(ExitCode::SUCCESS)
}
fn list_json(loaded: &LoadedConfig) -> Result<ExitCode> {
let rules: Vec<serde_json::Value> = loaded
.entries
.iter()
.map(|entry| {
serde_json::json!({
"id": entry.rule.id(),
"kind": entry.kind,
"categories": rules::categories_for_kind(&entry.kind),
"level": entry.rule.level().as_str(),
"policy_url": entry.rule.policy_url(),
"conditional": entry.when.is_some(),
"fixable": entry.rule.fixer().is_some(),
})
})
.collect();
let doc = serde_json::json!({ "schema_version": 1, "kind": "rule-inventory", "rules": rules });
let mut out = std::io::stdout().lock();
writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
out.flush().ok();
Ok(ExitCode::SUCCESS)
}
fn cmd_facts(path: &Path, cli: &Cli) -> Result<ExitCode> {
let loaded = load_rules(path, cli)?;
let effective_gitignore = if cli.no_gitignore {
false
} else {
loaded.respect_gitignore
};
let walk_opts = WalkOptions {
respect_gitignore: effective_gitignore,
extra_ignores: loaded.extra_ignores,
};
let index = walk(path, &walk_opts).context("walking repository")?;
let values =
alint_core::evaluate_facts(&loaded.facts, path, &index).context("evaluating facts")?;
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
match format {
Format::Human | Format::Json => {}
_ => bail!(
"`alint facts` supports only `--format human` or `--format json` (got {:?})",
cli.format
),
}
let (mut out, _opts) = render_env(cli)?;
render_facts(&loaded.facts, &values, format, &mut out)?;
out.flush().ok();
Ok(ExitCode::SUCCESS)
}
fn render_facts(
facts: &[alint_core::FactSpec],
values: &alint_core::FactValues,
format: Format,
out: &mut dyn Write,
) -> Result<()> {
match format {
Format::Json => render_facts_json(facts, values, out),
_ => render_facts_human(facts, values, out),
}
}
fn render_facts_human(
facts: &[alint_core::FactSpec],
values: &alint_core::FactValues,
out: &mut dyn Write,
) -> Result<()> {
use alint_output::style;
if facts.is_empty() {
writeln!(out, "(no facts declared in config)")?;
return Ok(());
}
let id_width = facts.iter().map(|f| f.id.len()).max().unwrap_or(0);
let kind_width = facts.iter().map(|f| f.kind.name().len()).max().unwrap_or(0);
let dim = style::DIM;
for spec in facts {
let value_str = values
.get(&spec.id)
.map_or_else(|| "(unresolved)".to_string(), fact_value_display);
let value_style = match value_str.as_str() {
"true" => style::SUCCESS,
"false" | "(unresolved)" => style::DIM,
_ => style::PATH, };
let kind_name = spec.kind.name();
let kind_pad = " ".repeat(kind_width.saturating_sub(kind_name.len()));
writeln!(
out,
"{:<id_width$} {dim}{kind_name}{dim:#}{kind_pad} {value_style}{value_str}{value_style:#}",
spec.id,
)?;
}
Ok(())
}
fn render_facts_json(
facts: &[alint_core::FactSpec],
values: &alint_core::FactValues,
out: &mut dyn Write,
) -> Result<()> {
let entries: Vec<serde_json::Value> = facts
.iter()
.map(|spec| {
let value = values
.get(&spec.id)
.map_or(serde_json::Value::Null, fact_value_json);
serde_json::json!({
"id": spec.id,
"kind": spec.kind.name(),
"value": value,
})
})
.collect();
let doc = serde_json::json!({ "schema_version": 1, "kind": "facts", "facts": entries });
writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
Ok(())
}
fn fact_value_display(v: &alint_core::FactValue) -> String {
match v {
alint_core::FactValue::Bool(b) => b.to_string(),
alint_core::FactValue::Int(n) => n.to_string(),
alint_core::FactValue::String(s) => {
format!("{s:?}")
}
}
}
fn fact_value_json(v: &alint_core::FactValue) -> serde_json::Value {
match v {
alint_core::FactValue::Bool(b) => serde_json::Value::Bool(*b),
alint_core::FactValue::Int(n) => serde_json::Value::Number((*n).into()),
alint_core::FactValue::String(s) => serde_json::Value::String(s.clone()),
}
}
fn cmd_explain(rule_id: &str, cli: &Cli) -> Result<ExitCode> {
use alint_core::Level;
use alint_output::style;
let loaded = load_rules(Path::new("."), cli)?;
let Some(entry) = loaded.entries.iter().find(|e| e.rule.id() == rule_id) else {
bail!("no rule with id {rule_id:?} found in the effective config");
};
let rule = &entry.rule;
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
match format {
Format::Human => {}
Format::Json => return explain_json(entry),
_ => bail!(
"`alint explain` supports only `--format human` or `--format json` (got {:?})",
cli.format
),
}
let (mut out, opts) = render_env(cli)?;
let dim = style::DIM;
let docs = style::DOCS;
let level_style = match rule.level() {
Level::Error => style::ERROR,
Level::Warning => style::WARNING,
Level::Info => style::INFO,
Level::Off => style::DIM,
};
writeln!(out, "{dim}id: {dim:#} {}", rule.id())?;
writeln!(
out,
"{dim}level: {dim:#} {level_style}{}{level_style:#}",
rule.level().as_str(),
)?;
if opts.show_docs
&& let Some(url) = rule.policy_url()
{
writeln!(out, "{dim}policy_url:{dim:#} {docs}{url}{docs:#}")?;
}
if let Some(when) = &entry.when {
writeln!(out, "{dim}when: {dim:#} {when:?}")?;
}
out.flush().ok();
Ok(ExitCode::SUCCESS)
}
fn explain_json(entry: &alint_core::RuleEntry) -> Result<ExitCode> {
let doc = serde_json::json!({
"schema_version": 1,
"kind": "rule",
"id": entry.rule.id(),
"level": entry.rule.level().as_str(),
"policy_url": entry.rule.policy_url(),
"conditional": entry.when.is_some(),
"fixable": entry.rule.fixer().is_some(),
});
let mut out = std::io::stdout().lock();
writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
out.flush().ok();
Ok(ExitCode::SUCCESS)
}
fn render_env(
cli: &Cli,
) -> Result<(
anstream::AutoStream<std::io::StdoutLock<'static>>,
HumanOptions,
)> {
let choice: ColorChoice = cli.color.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let choice = choice.resolve();
let stdout = io::stdout();
let is_tty = stdout.is_terminal();
let lock = stdout.lock();
let stream = anstream::AutoStream::new(lock, choice.to_anstream());
let force_hyperlinks =
std::env::var_os("ALINT_FORCE_HYPERLINKS").is_some_and(|v| !v.is_empty() && v != *"0");
let hyperlinks = force_hyperlinks
|| (is_tty && supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout));
let width = cli.width.or_else(|| {
if is_tty {
terminal_size::terminal_size().map(|(w, _)| usize::from(w.0))
} else {
None
}
});
let opts = HumanOptions {
glyphs: GlyphSet::detect(cli.ascii),
hyperlinks,
width,
compact: cli.compact,
show_docs: !cli.no_docs,
};
Ok((stream, opts))
}
struct LoadedConfig {
entries: Vec<alint_core::RuleEntry>,
registry: RuleRegistry,
facts: Vec<alint_core::FactSpec>,
vars: std::collections::HashMap<String, String>,
respect_gitignore: bool,
extra_ignores: Vec<String>,
fix_size_limit: Option<u64>,
baseline: Option<PathBuf>,
}
fn cmd_validate_config(path: Option<PathBuf>, format: &str, cli: &Cli) -> Result<ExitCode> {
let fmt: Format = format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
if !matches!(fmt, Format::Human | Format::Json) {
bail!(
"`alint validate-config` supports only `--format human` or `--format json` (got {format:?})"
);
}
let config_path: PathBuf = if let Some(p) = path {
if p.is_dir() {
if let Some(found) = alint_dsl::discover(&p) {
found
} else {
let err = anyhow::anyhow!(
"no .alint.yml found under directory {} \
(run `alint init` there to scaffold one)",
p.display()
);
return emit_validate_failure(&err, None, format);
}
} else {
p
}
} else if let Some(first) = cli.config.first() {
first.clone()
} else if let Some(p) = alint_dsl::discover(Path::new(".")) {
p
} else {
let err = anyhow::anyhow!(
"no .alint.yml found (searched from {}) \
(run `alint init` to scaffold one)",
Path::new(".").display()
);
return emit_validate_failure(&err, None, format);
};
if !config_path.exists() {
let err = anyhow::anyhow!("config file not found: {}", config_path.display());
return emit_validate_failure(&err, Some(&config_path), format);
}
match validate_config_inner(&config_path) {
Ok(rule_count) => emit_validate_success(rule_count, &config_path, format),
Err(e) => emit_validate_failure(&e, Some(&config_path), format),
}
}
fn validate_config_inner(config_path: &Path) -> Result<usize> {
let config = alint_dsl::load(config_path)?;
let registry: alint_core::RuleRegistry = alint_rules::builtin_registry();
let mut count = 0usize;
for spec in &config.rules {
if matches!(spec.level, alint_core::Level::Off) {
continue;
}
let rule = registry
.build(spec)
.with_context(|| format!("building rule {:?}", spec.id))?;
rule.validate_nested(®istry)
.with_context(|| format!("building rule {:?}", spec.id))?;
if let Some(when_src) = &spec.when {
alint_core::when::parse(when_src)
.with_context(|| format!("rule {:?}: parsing `when`", spec.id))?;
}
count += 1;
}
Ok(count)
}
fn emit_validate_success(rule_count: usize, config_path: &Path, format: &str) -> Result<ExitCode> {
if format == "json" {
let envelope = serde_json::json!({
"valid": true,
"rule_count": rule_count,
"config_path": config_path.display().to_string(),
"error": serde_json::Value::Null,
});
println!("{}", serde_json::to_string(&envelope)?);
} else {
println!(
"✓ Config valid: {rule_count} rule(s) loaded from {}",
config_path.display()
);
}
Ok(ExitCode::SUCCESS)
}
fn emit_validate_failure(
err: &anyhow::Error,
config_path: Option<&Path>,
format: &str,
) -> Result<ExitCode> {
if format == "json" {
let chain = format!("{err:#}");
let envelope = serde_json::json!({
"valid": false,
"rule_count": 0,
"config_path": config_path.map(|p| p.display().to_string()),
"error": chain,
});
println!("{}", serde_json::to_string(&envelope)?);
} else {
eprintln!("alint: {err:#}");
println!("✗ Config invalid");
}
if error_is_internal(err) {
Ok(ExitCode::from(3))
} else {
Ok(ExitCode::from(1))
}
}
fn load_rules(cwd: &Path, cli: &Cli) -> Result<LoadedConfig> {
let config_path = if let Some(first) = cli.config.first() {
first.clone()
} else {
alint_dsl::discover(cwd).ok_or_else(|| {
anyhow::anyhow!(
"no .alint.yml found (searched from {}) \
(run `alint init` to scaffold one)",
cwd.display()
)
})?
};
tracing::debug!(?config_path, "loading config");
let config = alint_dsl::load(&config_path)?;
let registry: RuleRegistry = alint_rules::builtin_registry();
let mut entries: Vec<alint_core::RuleEntry> = Vec::with_capacity(config.rules.len());
for spec in &config.rules {
if matches!(spec.level, alint_core::Level::Off) {
continue;
}
let mut rule = registry
.build(spec)
.with_context(|| format!("building rule {:?}", spec.id))?;
rule.validate_nested(®istry)
.with_context(|| format!("building rule {:?}", spec.id))?;
let allow_out_of_root = config.allow_out_of_root.allows(&spec.id, &spec.kind);
rule.set_allow_out_of_root(allow_out_of_root);
let mut entry = alint_core::RuleEntry::new(rule)
.with_kind(spec.kind.clone())
.with_allow_out_of_root(allow_out_of_root);
if let Some(when_src) = &spec.when {
let expr = alint_core::when::parse(when_src)
.with_context(|| format!("rule {:?}: parsing `when`", spec.id))?;
entry = entry.with_when(expr);
}
entries.push(entry);
}
Ok(LoadedConfig {
entries,
registry,
facts: config.facts,
vars: config.vars,
respect_gitignore: config.respect_gitignore,
extra_ignores: config.ignore,
fix_size_limit: config.fix_size_limit,
baseline: config.baseline,
})
}
#[cfg(test)]
mod tests;