pub mod config;
pub mod rules;
use std::collections::{BTreeSet, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use rayon::prelude::*;
use crate::config::{ConfigManager, ExcludeSet};
use crate::edit::{Edit, apply_edits};
use crate::languages::registry::{LanguageRegistry, warn_languages_without_a_grammar};
use crate::paths::{absolute_normalized, find_repo_root, repo_relative, to_slash};
use crate::processor::Processor;
use crate::scan::id::{assign_occurrence_indices, comment_id};
use crate::ui;
use self::config::{LintConfig, Rule, Severity};
const FALLBACK_BASE_REF: &str = "main";
const BASELINE_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormat {
#[default]
Text,
Json,
}
#[derive(clap::Args, Debug)]
pub struct LintArgs {
#[command(flatten)]
pub process: crate::cli::ProcessArgs,
#[arg(long, help = "Apply the fixes that can be applied safely", help_heading = "Linting")]
pub fix: bool,
#[arg(
long = "changed-only",
help = "Lint only files changed against --base",
help_heading = "Linting"
)]
pub changed_only: bool,
#[arg(
long,
value_name = "REF",
help = "Base ref for --changed-only (default: origin's default branch)",
help_heading = "Linting"
)]
pub base: Option<String>,
#[arg(
long = "todo-key",
value_name = "KEY",
help = "Issue key to insert into tag comments that have none (with --fix)",
help_heading = "Linting"
)]
pub todo_key: Option<String>,
#[arg(
long,
value_name = "FILE",
help = "Treat violations recorded in FILE as informational",
help_heading = "Linting"
)]
pub baseline: Option<PathBuf>,
#[arg(
long = "write-baseline",
requires = "baseline",
help = "Write the current violations to --baseline and exit 0",
help_heading = "Linting"
)]
pub write_baseline: bool,
#[arg(
long,
value_enum,
default_value = "text",
value_name = "FORMAT",
help = "Output format",
help_heading = "Linting"
)]
pub format: OutputFormat,
}
#[derive(Debug, Clone)]
pub struct Violation {
pub path: PathBuf,
pub id: String,
pub rule: Rule,
pub severity: Severity,
pub line: usize,
pub column: usize,
pub message: String,
pub excerpt: String,
pub tag: String,
pub key: Option<String>,
pub fixed: bool,
pub baselined: bool,
}
impl Violation {
fn is_failing(&self) -> bool {
self.severity == Severity::Error && !self.baselined && !self.fixed
}
}
#[derive(Debug, Default, Clone)]
pub struct Summary {
pub files_linted: usize,
pub files_skipped_disabled: usize,
pub fixed: usize,
pub baselined: usize,
pub errors: usize,
pub warnings: usize,
pub uninspectable: usize,
}
#[derive(Debug)]
pub struct Outcome {
pub violations: Vec<Violation>,
pub notes: Vec<String>,
pub summary: Summary,
}
impl Outcome {
pub fn exit_code(&self) -> i32 {
i32::from(self.failing() > 0 || self.summary.uninspectable > 0)
}
pub fn failing(&self) -> usize {
self.violations.iter().filter(|v| v.is_failing()).count()
}
pub fn by_rule(&self, rule: Rule) -> Vec<&Violation> {
self.violations.iter().filter(|v| v.rule == rule).collect()
}
}
pub fn run(args: &LintArgs) -> Result<i32> {
let cwd = std::env::current_dir().context("failed to read the current directory")?;
run_in(&cwd, args)
}
pub fn run_in(base: &Path, args: &LintArgs) -> Result<i32> {
let outcome = lint(base, args)?;
if let (Some(path), true) = (&args.baseline, args.write_baseline) {
write_baseline(path, &outcome.violations)?;
if args.format == OutputFormat::Text {
anstream::println!(
"{} recorded {} violation(s) in {}",
ui::success(ui::CHECK),
ui::accent(outcome.violations.len()),
ui::path(path)
);
}
return Ok(0);
}
report(&outcome, args.format)?;
Ok(outcome.exit_code())
}
pub fn lint(base: &Path, args: &LintArgs) -> Result<Outcome> {
let base = absolute_normalized(&std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), base);
let repo_root = find_repo_root(&base);
let mut notes: Vec<String> = Vec::new();
if args.process.paths.is_empty() {
bail!("no input paths specified: pass one or more files or directories to lint");
}
let respect_gitignore = !args.process.no_gitignore;
let mut config_manager = build_config_manager(&base, args.process.config.as_deref())?;
config_manager.discover_language_sources(&args.process.paths, respect_gitignore);
let mut registry = LanguageRegistry::new();
warn_languages_without_a_grammar(®istry.register_configured_languages(&config_manager.get_all_languages()));
let excludes = config_manager.exclude_set(&args.process.exclude)?;
let mut files = collect_files(&base, &args.process.paths, respect_gitignore, ®istry, &excludes)?;
if args.changed_only {
let root = repo_root
.as_deref()
.context("--changed-only needs a git repository, and none encloses the base directory")?;
let base_ref = match &args.base {
Some(given) => given.clone(),
None => default_base_ref(root),
};
let changed = changed_files(root, &base_ref)?;
let before = files.len();
files.retain(|file| changed.contains(&absolute_normalized(&base, file)));
notes.push(format!(
"--changed-only against {base_ref}: {} of {before} file(s) changed",
files.len()
));
}
let mut resolver = config::Resolver::new(&base, &config_manager);
let mut targets: Vec<(PathBuf, Arc<LintConfig>)> = Vec::with_capacity(files.len());
let mut disabled = 0usize;
for file in files {
let config = resolver.for_file(&file)?;
if config.enabled {
targets.push((file, config));
} else {
disabled += 1;
}
}
if let Some(error) = config_manager.deferred_config_error() {
bail!("{error}");
}
let mut summary = Summary {
files_linted: targets.len(),
files_skipped_disabled: disabled,
..Summary::default()
};
if targets.is_empty() {
if disabled > 0 {
notes.push(format!(
"lint is not enabled for {disabled} matched file(s): set `enabled = true` under `[lint]` in \
.uncomment.toml"
));
}
return Ok(Outcome {
violations: Vec::new(),
notes,
summary,
});
}
let current_issue = resolve_current_issue(repo_root.as_deref(), &targets, &mut notes);
let baseline = match (&args.baseline, args.write_baseline) {
(Some(path), false) => load_baseline(path)?,
_ => HashSet::new(),
};
let context = FileContext {
repo_root: repo_root.clone(),
base: base.clone(),
current_issue,
todo_key: args.todo_key.clone(),
fix: args.fix && !args.write_baseline,
baseline,
};
let threads = if args.process.threads == 0 {
num_cpus::get()
} else {
args.process.threads
};
let lint_one = |(path, config): &(PathBuf, Arc<LintConfig>)| lint_file(path, config, &config_manager, &context);
let outcomes: Vec<FileOutcome> = if threads <= 1 {
targets.iter().map(lint_one).collect()
} else {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.context("failed to build the lint thread pool")?;
pool.install(|| targets.par_iter().map(lint_one).collect())
};
let mut violations = Vec::new();
for outcome in outcomes {
match outcome {
FileOutcome::Linted { violations: found } => violations.extend(found),
FileOutcome::Skipped { note } => {
summary.files_linted -= 1;
notes.push(note);
}
FileOutcome::Failed { path, error } => {
summary.files_linted -= 1;
summary.uninspectable += 1;
notes.push(format!("{}: {error}", to_slash(&path)));
}
}
}
violations.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.column.cmp(&b.column))
.then(a.rule.cmp(&b.rule))
});
for violation in &violations {
if violation.baselined {
summary.baselined += 1;
}
if violation.fixed {
summary.fixed += 1;
}
match violation.severity {
Severity::Error => summary.errors += 1,
Severity::Warn => summary.warnings += 1,
Severity::Off => {}
}
}
Ok(Outcome {
violations,
notes,
summary,
})
}
struct FileContext {
repo_root: Option<PathBuf>,
base: PathBuf,
current_issue: Option<String>,
todo_key: Option<String>,
fix: bool,
baseline: HashSet<(String, Rule)>,
}
enum FileOutcome {
Linted { violations: Vec<Violation> },
Skipped { note: String },
Failed { path: PathBuf, error: String },
}
fn lint_file(path: &Path, config: &LintConfig, config_manager: &ConfigManager, context: &FileContext) -> FileOutcome {
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(error) => {
return FileOutcome::Skipped {
note: format!("skipped {}: {error}", to_slash(path)),
};
}
};
let resolved = config_manager.get_config_for_file(path);
let mut processor = Processor::new_with_config(config_manager);
let comments = match processor.inspect(&content, path, &resolved) {
Ok(comments) => comments,
Err(error) => {
return FileOutcome::Failed {
path: path.to_path_buf(),
error: format!("{error:#}"),
};
}
};
let texts: Vec<&str> = comments.iter().map(|comment| comment.text.as_str()).collect();
let occurrences = assign_occurrence_indices(&texts);
let id_path = id_path(context.repo_root.as_deref(), path, &context.base);
let display = display_path(path, &context.base);
let mut violations = Vec::new();
let mut edits: Vec<Edit> = Vec::new();
for (comment, occurrence) in comments.iter().zip(occurrences) {
let findings = rules::check(
comment,
config,
context.current_issue.as_deref(),
context.todo_key.as_deref(),
);
if findings.is_empty() {
continue;
}
let id = comment_id(&id_path, &comment.text, occurrence);
for mut finding in findings {
if finding.rule == Rule::TagNotCanonical {
finding.message = config.tag_not_canonical_message(&finding.tag);
}
let (line, column) = line_and_column(&content, finding.offset);
let fixed = context.fix && finding.is_fixable();
if fixed {
edits.extend(finding.edits.iter().cloned());
}
violations.push(Violation {
path: display.clone(),
baselined: context.baseline.contains(&(id.clone(), finding.rule)),
id: id.clone(),
rule: finding.rule,
severity: finding.severity,
line,
column,
message: finding.message,
excerpt: finding.excerpt,
tag: finding.tag,
key: finding.key,
fixed,
});
}
}
if !edits.is_empty() {
match apply_edits(&content, edits)
.and_then(|fixed| fs::write(path, &fixed).with_context(|| format!("failed to write {}", path.display())))
{
Ok(()) => {}
Err(error) => {
return FileOutcome::Failed {
path: path.to_path_buf(),
error: format!("{error:#}"),
};
}
}
}
FileOutcome::Linted { violations }
}
fn resolve_current_issue(
repo_root: Option<&Path>,
targets: &[(PathBuf, Arc<LintConfig>)],
notes: &mut Vec<String>,
) -> Option<String> {
let (_, config) = targets
.iter()
.find(|(_, config)| config.is_on(Rule::TodoSelfReference))?;
let skipped = |notes: &mut Vec<String>, reason: &str| {
notes.push(format!("{} not checked: {reason}", Rule::TodoSelfReference.as_str()));
None
};
let Some(root) = repo_root else {
return skipped(notes, "no git repository encloses the files being linted");
};
let Some(branch) = crate::git::current_branch(root) else {
return skipped(notes, "HEAD is detached or unreadable");
};
match crate::git::current_issue_key(root, &config.branch_pattern) {
Some(key) => Some(key),
None => skipped(
notes,
&format!("branch `{branch}` matches lint.current_issue_from_branch nowhere"),
),
}
}
fn default_base_ref(repo_root: &Path) -> String {
let Some(common) = git_common_dir(repo_root) else {
return FALLBACK_BASE_REF.to_string();
};
fs::read_to_string(common.join("refs/remotes/origin/HEAD"))
.ok()
.and_then(|content| {
let reference = content.trim().strip_prefix("ref:")?.trim();
let branch = reference.strip_prefix("refs/remotes/origin/")?;
(!branch.is_empty()).then(|| branch.to_string())
})
.unwrap_or_else(|| FALLBACK_BASE_REF.to_string())
}
fn git_common_dir(repo_root: &Path) -> Option<PathBuf> {
let dot_git = repo_root.join(".git");
let metadata = fs::metadata(&dot_git).ok()?;
let git_dir = if metadata.is_dir() {
dot_git
} else {
let pointer = fs::read_to_string(&dot_git).ok()?;
let pointer = pointer.trim().strip_prefix("gitdir:")?.trim();
if pointer.is_empty() {
return None;
}
absolute_normalized(repo_root, Path::new(pointer))
};
match fs::read_to_string(git_dir.join("commondir")) {
Ok(common) => Some(absolute_normalized(&git_dir, Path::new(common.trim()))),
Err(_) => Some(git_dir),
}
}
fn changed_files(repo_root: &Path, base: &str) -> Result<HashSet<PathBuf>> {
let range = format!("{base}...HEAD");
let output = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["diff", "--name-only", &range])
.output()
.with_context(|| format!("failed to run `git diff --name-only {range}`"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("`git diff --name-only {range}` failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| absolute_normalized(repo_root, Path::new(line)))
.collect())
}
fn id_path(repo_root: Option<&Path>, file: &Path, base: &Path) -> String {
let absolute = absolute_normalized(base, file);
let anchor = repo_root.unwrap_or(base);
to_slash(&repo_relative(anchor, &absolute).unwrap_or(absolute))
}
fn display_path(file: &Path, base: &Path) -> PathBuf {
let absolute = absolute_normalized(base, file);
repo_relative(base, &absolute).unwrap_or(absolute)
}
fn line_and_column(content: &str, offset: usize) -> (usize, usize) {
let offset = offset.min(content.len());
let preceding = &content[..offset];
let line = preceding.matches('\n').count() + 1;
let column = offset - preceding.rfind('\n').map_or(0, |index| index + 1) + 1;
(line, column)
}
fn build_config_manager(base: &Path, forced: Option<&Path>) -> Result<ConfigManager> {
match forced {
Some(path) => {
let path = absolute_normalized(base, path);
ConfigManager::from_config_file(base, &path)
.with_context(|| format!("failed to load config file: {}", path.display()))
}
None => ConfigManager::new(base).context("failed to initialize configuration manager"),
}
}
fn collect_files(
base: &Path,
paths: &[String],
respect_gitignore: bool,
registry: &LanguageRegistry,
excludes: &ExcludeSet,
) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for pattern in paths {
let path = absolute_normalized(base, Path::new(pattern));
if path.is_file() {
if !excludes.is_excluded(&path) && registry.detect_language(&path).is_some() {
files.push(path);
}
} else if path.is_dir() {
if !excludes.prunes_dir(&path) {
walk_dir(&path, respect_gitignore, registry, excludes, &mut files);
}
} else {
let pattern = to_slash(&path);
for entry in glob::glob(&pattern).with_context(|| format!("invalid path or glob: {pattern}"))? {
let entry = entry.with_context(|| format!("failed to read a match for {pattern}"))?;
if entry.is_file() && !excludes.is_excluded(&entry) && registry.detect_language(&entry).is_some() {
files.push(entry);
}
}
}
}
files.sort();
files.dedup();
Ok(files)
}
fn walk_dir(
dir: &Path,
respect_gitignore: bool,
registry: &LanguageRegistry,
excludes: &ExcludeSet,
files: &mut Vec<PathBuf>,
) {
let walker = ignore::WalkBuilder::new(dir)
.hidden(false)
.git_ignore(respect_gitignore)
.git_global(respect_gitignore)
.git_exclude(respect_gitignore)
.parents(respect_gitignore)
.require_git(false)
.filter_entry(excludes.walk_filter())
.build();
for entry in walker.flatten() {
let path = entry.path();
if path.is_file() && !excludes.is_excluded(path) && registry.detect_language(path).is_some() {
files.push(path.to_path_buf());
}
}
}
fn load_baseline(path: &Path) -> Result<HashSet<(String, Rule)>> {
let text = fs::read_to_string(path).with_context(|| {
format!(
"failed to read baseline {} (write one with --write-baseline)",
path.display()
)
})?;
let document: serde_json::Value =
serde_json::from_str(&text).with_context(|| format!("failed to parse baseline {}", path.display()))?;
let version = document.get("version").and_then(serde_json::Value::as_u64);
if version != Some(u64::from(BASELINE_VERSION)) {
bail!(
"baseline {} has version {:?}, expected {BASELINE_VERSION}: rewrite it with --write-baseline",
path.display(),
version
);
}
let mut entries = HashSet::new();
let recorded = document
.get("violations")
.and_then(serde_json::Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
for entry in recorded {
let id = entry.get("id").and_then(serde_json::Value::as_str);
let rule = entry
.get("rule")
.and_then(serde_json::Value::as_str)
.and_then(rule_from_str);
if let (Some(id), Some(rule)) = (id, rule) {
entries.insert((id.to_string(), rule));
}
}
Ok(entries)
}
fn write_baseline(path: &Path, violations: &[Violation]) -> Result<()> {
let mut entries: BTreeSet<(String, &'static str, String, String)> = BTreeSet::new();
for violation in violations {
entries.insert((
violation.id.clone(),
violation.rule.as_str(),
to_slash(&violation.path),
violation.excerpt.clone(),
));
}
let document = serde_json::json!({
"version": BASELINE_VERSION,
"violations": entries
.into_iter()
.map(|(id, rule, path, excerpt)| serde_json::json!({
"id": id,
"rule": rule,
"path": path,
"excerpt": excerpt,
}))
.collect::<Vec<_>>(),
});
let mut rendered = serde_json::to_string_pretty(&document).context("failed to render the baseline")?;
rendered.push('\n');
fs::write(path, rendered).with_context(|| format!("failed to write baseline {}", path.display()))
}
fn rule_from_str(name: &str) -> Option<Rule> {
Rule::ALL.into_iter().find(|rule| rule.as_str() == name)
}
fn report(outcome: &Outcome, format: OutputFormat) -> Result<()> {
match format {
OutputFormat::Text => report_text(outcome),
OutputFormat::Json => report_json(outcome),
}
}
fn report_text(outcome: &Outcome) -> Result<()> {
let Outcome {
violations,
notes,
summary,
} = outcome;
for note in notes {
anstream::eprintln!("{} {}", ui::dim(ui::BULLET), ui::dim(note));
}
for violation in violations {
let label = if violation.fixed {
ui::success("fixed")
} else if violation.baselined {
ui::dim("baselined")
} else {
match violation.severity {
Severity::Error => ui::danger("error"),
Severity::Warn => ui::warn("warning"),
Severity::Off => ui::dim("off"),
}
};
anstream::println!(
"{}:{}:{} {} [{}] {}",
ui::path(&violation.path),
violation.line,
violation.column,
label,
ui::accent(violation.rule.as_str()),
violation.message
);
if !violation.excerpt.is_empty() {
anstream::println!(" {}", ui::dim(&violation.excerpt));
}
}
let failing = outcome.failing();
let mut detail = vec![format!("{} file(s) linted", summary.files_linted)];
if summary.baselined > 0 {
detail.push(format!("{} baselined", summary.baselined));
}
if summary.fixed > 0 {
detail.push(format!("{} fixed", summary.fixed));
}
if summary.warnings > 0 {
detail.push(format!("{} warning(s)", summary.warnings));
}
if summary.uninspectable > 0 {
detail.push(format!("{} file(s) could not be inspected", summary.uninspectable));
}
if failing == 0 && summary.uninspectable == 0 {
anstream::println!(
"{} {} ({})",
ui::success(ui::CHECK),
ui::success("no lint violations"),
ui::dim(detail.join(", "))
);
} else {
anstream::println!(
"{} {} ({})",
ui::danger("✗"),
ui::danger(format!("{failing} violation(s)")),
ui::dim(detail.join(", "))
);
}
Ok(())
}
fn report_json(outcome: &Outcome) -> Result<()> {
let Outcome {
violations,
notes,
summary,
} = outcome;
let document = serde_json::json!({
"violations": violations
.iter()
.map(|violation| serde_json::json!({
"path": to_slash(&violation.path),
"line": violation.line,
"column": violation.column,
"rule": violation.rule.as_str(),
"severity": violation.severity.as_str(),
"message": violation.message,
"excerpt": violation.excerpt,
"tag": violation.tag,
"key": violation.key,
"id": violation.id,
"baselined": violation.baselined,
"fixed": violation.fixed,
}))
.collect::<Vec<_>>(),
"notes": notes,
"summary": {
"files_linted": summary.files_linted,
"files_skipped_lint_disabled": summary.files_skipped_disabled,
"violations": violations.len(),
"errors": summary.errors,
"warnings": summary.warnings,
"baselined": summary.baselined,
"fixed": summary.fixed,
"uninspectable_files": summary.uninspectable,
"failing": outcome.failing(),
},
});
anstream::println!(
"{}",
serde_json::to_string_pretty(&document).context("failed to render the lint report")?
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn line_and_column_are_one_based() {
let content = "alpha\nbeta\ngamma\n";
assert_eq!(line_and_column(content, 0), (1, 1));
assert_eq!(line_and_column(content, 6), (2, 1));
assert_eq!(line_and_column(content, 8), (2, 3));
assert_eq!(line_and_column(content, content.len()), (4, 1));
}
#[test]
fn a_rule_name_round_trips() {
for rule in Rule::ALL {
assert_eq!(rule_from_str(rule.as_str()), Some(rule));
}
assert_eq!(rule_from_str("no-such-rule"), None);
}
#[test]
fn the_default_base_ref_is_read_from_origin_head_and_never_master() {
let temp = tempfile::TempDir::new().expect("temp dir");
let root = temp.path();
let refs = root.join(".git/refs/remotes/origin");
fs::create_dir_all(&refs).expect("create refs");
assert_eq!(default_base_ref(root), "main");
fs::write(refs.join("HEAD"), "ref: refs/remotes/origin/trunk\n").expect("write HEAD");
assert_eq!(default_base_ref(root), "trunk");
fs::write(refs.join("HEAD"), "ref: refs/remotes/origin/master\n").expect("write HEAD");
assert_eq!(
default_base_ref(root),
"master",
"a repository that really uses master is still honoured"
);
}
#[test]
fn a_worktree_resolves_refs_through_commondir() {
let temp = tempfile::TempDir::new().expect("temp dir");
let main_git = temp.path().join("main/.git");
fs::create_dir_all(main_git.join("refs/remotes/origin")).expect("create refs");
fs::create_dir_all(main_git.join("worktrees/wt")).expect("create worktree dir");
fs::write(
main_git.join("refs/remotes/origin/HEAD"),
"ref: refs/remotes/origin/main\n",
)
.expect("write HEAD");
fs::write(main_git.join("worktrees/wt/commondir"), "../..\n").expect("write commondir");
let worktree = temp.path().join("wt");
fs::create_dir_all(&worktree).expect("create worktree");
fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", main_git.join("worktrees/wt").display()),
)
.expect("write .git");
assert_eq!(default_base_ref(&worktree), "main");
}
}