Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions default.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
{
"matchPackageNames": [
"actionlint",
"biome",
"cargo:yaml-lint",
"cargo:xmloxide",
"editorconfig-checker",
"github:google/google-java-format",
Expand All @@ -46,12 +48,10 @@
"golangci-lint",
"hadolint",
"lychee",
"npm:@biomejs/biome",
"npm:markdownlint-cli2",
"npm:prettier",
"npm:renovate",
"pipx:codespell",
"pipx:ruff",
"rumdl",
"shellcheck",
"shfmt"
],
Expand Down
9 changes: 7 additions & 2 deletions src/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub fn changed(
let exclude = build_exclude_set(cfg);

if full {
return all_files(project_root, &exclude);
return all(project_root, cfg);
}

let merge_base = resolve_merge_base(project_root, cfg, from_ref)?;
Expand All @@ -39,7 +39,7 @@ pub fn changed(
collect_changed_files(project_root, &exclude, base, to)?
} else {
// No merge base (shallow clone etc.) — fall back to all files.
return all_files(project_root, &exclude);
return all(project_root, cfg);
};

Ok(FileList {
Expand Down Expand Up @@ -113,6 +113,11 @@ fn collect_changed_files(
Ok(filter_names(project_root, exclude, names))
}

pub fn all(project_root: &Path, cfg: &Config) -> Result<FileList> {
let exclude = build_exclude_set(cfg);
all_files(project_root, &exclude)
}

fn all_files(project_root: &Path, exclude: &GlobSet) -> Result<FileList> {
let out = Command::new("git")
.args(["ls-files"])
Expand Down
235 changes: 209 additions & 26 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use registry::{CheckKind, FixBehavior, RunPolicy, Scope, SpecialKind};
use runner::{CheckResult, RunOptions};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

#[derive(Parser, Debug)]
#[command(name = "flint", about = "flint — fast lint")]
Expand Down Expand Up @@ -259,6 +260,19 @@ async fn run(
}
}

let baseline_names = baseline_check_names(&active, &file_list, project_root, config_dir);
let baseline_file_list = if baseline_names.is_empty() {
None
} else {
Some(files::all(project_root, &cfg)?)
};
let run_ctx = RunContext {
active_checks: &active,
project_root,
cfg: &cfg,
config_dir,
};

if args.fix {
// Exits 0 if everything was already clean; 1 if anything was fixed (uncommitted)
// or still needs review.
Expand All @@ -274,19 +288,18 @@ async fn run(
let mut post_fix_failed = vec![];

if !legacy_checks.is_empty() {
let check_results = runner::run(
let check_results = run_checks(
&legacy_checks,
&active,
&file_list,
baseline_file_list.as_ref(),
&baseline_names,
RunOptions {
fix: false,
verbose: false,
short: true,
time: false,
},
project_root,
&cfg,
config_dir,
run_ctx,
)
.await?;

Expand All @@ -304,19 +317,18 @@ async fn run(
.filter(|c| fixable_names.contains(&c.name))
.copied()
.collect();
let fix_results = runner::run(
let fix_results = run_checks(
&to_fix,
&active,
&file_list,
baseline_file_list.as_ref(),
&baseline_names,
RunOptions {
fix: true,
verbose: false,
short: true,
time: false,
},
project_root,
&cfg,
config_dir,
run_ctx,
)
.await?;
for r in fix_results {
Expand All @@ -340,19 +352,18 @@ async fn run(
.filter(|c| verify_names.contains(&c.name))
.copied()
.collect();
let verify_results = runner::run(
let verify_results = run_checks(
&to_verify_checks,
&active,
&file_list,
baseline_file_list.as_ref(),
&baseline_names,
RunOptions {
fix: false,
verbose: false,
short: true,
time: false,
},
project_root,
&cfg,
config_dir,
run_ctx,
)
.await?;
for r in verify_results {
Expand All @@ -366,19 +377,18 @@ async fn run(
}

if !single_pass_fixable.is_empty() {
let fix_results = runner::run(
let fix_results = run_checks(
&single_pass_fixable,
&active,
&file_list,
baseline_file_list.as_ref(),
&baseline_names,
RunOptions {
fix: true,
verbose: false,
short: true,
time: false,
},
project_root,
&cfg,
config_dir,
run_ctx,
)
.await?;
for r in fix_results {
Expand Down Expand Up @@ -430,19 +440,18 @@ async fn run(
return Ok(());
}

let results = runner::run(
&active,
let results = run_checks(
&active,
&file_list,
baseline_file_list.as_ref(),
&baseline_names,
RunOptions {
fix: false,
verbose: args.verbose,
short: args.short,
time: args.time,
},
project_root,
&cfg,
config_dir,
run_ctx,
)
.await?;

Expand Down Expand Up @@ -485,6 +494,180 @@ async fn run(
Ok(())
}

#[derive(Clone, Copy)]
struct RunContext<'a> {
active_checks: &'a [&'a registry::Check],
project_root: &'a Path,
cfg: &'a config::Config,
config_dir: &'a Path,
}

async fn run_checks(
checks: &[&registry::Check],
file_list: &files::FileList,
baseline_file_list: Option<&files::FileList>,
baseline_names: &HashSet<String>,
opts: RunOptions,
ctx: RunContext<'_>,
) -> Result<Vec<CheckResult>> {
let (baseline, normal): (Vec<_>, Vec<_>) = checks
.iter()
.copied()
.partition(|c| baseline_names.contains(c.name));

let mut results = vec![];
if !normal.is_empty() {
results.extend(
runner::run(
&normal,
ctx.active_checks,
file_list,
opts,
ctx.project_root,
ctx.cfg,
ctx.config_dir,
)
.await?,
);
}
if !baseline.is_empty() {
let files = baseline_file_list.unwrap_or(file_list);
results.extend(
runner::run(
&baseline,
ctx.active_checks,
files,
opts,
ctx.project_root,
ctx.cfg,
ctx.config_dir,
)
.await?,
);
}
results.sort_by(|a, b| a.name.cmp(&b.name));
Ok(results)
}

fn baseline_check_names(
active: &[&registry::Check],
file_list: &files::FileList,
project_root: &Path,
config_dir: &Path,
) -> HashSet<String> {
if file_list.full {
return HashSet::new();
}
let Some(merge_base) = file_list.merge_base.as_deref() else {
return HashSet::new();
};

let changed = changed_rel_paths(file_list, project_root);
let previous_tools = registry::read_mise_tools_at_ref(project_root, merge_base);
let flint_config = config_rel_path(project_root, config_dir, "flint.toml");
let flint_config_changed = changed.contains(&flint_config);
let flint_toml =
flint_config_changed.then(|| flint_toml_change(project_root, config_dir, merge_base));

active
.iter()
.filter(|check| {
!registry::check_active(check, &previous_tools)
|| flint_toml.as_ref().is_some_and(|change| {
change.settings_changed
|| (matches!(check.kind, CheckKind::Special(_))
&& change.check_changed(check.name))
})
|| check.linter_config.is_some_and(|(file, _)| {
changed.contains(&config_rel_path(project_root, config_dir, file))
})
Comment thread
zeitlinger marked this conversation as resolved.
})
.map(|check| check.name.to_string())
.collect()
}

struct FlintTomlChange {
current: toml::Value,
previous: toml::Value,
settings_changed: bool,
}

impl FlintTomlChange {
fn check_changed(&self, name: &str) -> bool {
toml_section(&self.current, &["checks", name])
!= toml_section(&self.previous, &["checks", name])
}
}

fn flint_toml_change(project_root: &Path, config_dir: &Path, merge_base: &str) -> FlintTomlChange {
let rel = config_rel_path(project_root, config_dir, "flint.toml");
let current_path = project_root.join(&rel);
let current = read_toml_file(&current_path);
let previous = read_toml_at_ref(project_root, merge_base, &rel);
let settings_changed =
toml_section(&current, &["settings"]) != toml_section(&previous, &["settings"]);
FlintTomlChange {
current,
previous,
settings_changed,
}
}

fn read_toml_file(path: &Path) -> toml::Value {
std::fs::read_to_string(path)
.ok()
.and_then(|content| toml::from_str(&content).ok())
.unwrap_or(toml::Value::Table(Default::default()))
}

fn read_toml_at_ref(project_root: &Path, git_ref: &str, rel_path: &str) -> toml::Value {
let spec = format!("{git_ref}:{rel_path}");
std::process::Command::new("git")
.args(["show", &spec])
.current_dir(project_root)
.output()
.ok()
.filter(|out| out.status.success())
.and_then(|out| String::from_utf8(out.stdout).ok())
.and_then(|content| toml::from_str(&content).ok())
.unwrap_or(toml::Value::Table(Default::default()))
}

fn toml_section<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Value> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
Some(current)
}

fn changed_rel_paths(file_list: &files::FileList, project_root: &Path) -> HashSet<String> {
file_list
.files
.iter()
.filter_map(|path| path.strip_prefix(project_root).ok())
.map(normalize_path)
.collect()
}

fn config_rel_path(project_root: &Path, config_dir: &Path, file: &str) -> String {
let path = if config_dir.is_absolute() {
config_dir.join(file)
} else {
project_root.join(config_dir).join(file)
};
path.strip_prefix(project_root)
.map(normalize_path)
.unwrap_or_else(|_| normalize_path(&PathBuf::from(file)))
}

fn normalize_path(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}

fn print_linters_json(registry: &[registry::Check]) {
let entries: Vec<serde_json::Value> = registry.iter().map(linter_json).collect();
println!("{}", serde_json::to_string_pretty(&entries).unwrap());
Expand Down
2 changes: 1 addition & 1 deletion src/registry/checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::linters::renovate_deps::RENOVATE_CONFIG_PATTERNS;
fn check_shellcheck() -> Check {
Check::file(
"shellcheck",
"shellcheck {FILE}",
"shellcheck -x -P SCRIPTDIR {FILE}",
&["*.sh", "*.bash", "*.bats"],
)
.linter_config(".shellcheckrc", "--rcfile")
Expand Down
Loading
Loading