use anyhow::bail;
use core::cmp::Ordering;
use std::collections::HashSet;
const DATA: Profile = Profile {
ops: &[],
prefixes: &[],
default_ops: &[],
backend: false,
text_lints: TextLints::None,
};
const DATA_EXTENSIONS: &[&str] = &["ini", "json", "toml", "yaml", "yml"];
pub(crate) const DEFAULT_EXTENSIONS: &[&str] = &{
let mut out = [""; LANG_ENTRIES.len()];
let mut i = 0;
while i < LANG_ENTRIES.len() {
out[i] = LANG_ENTRIES[i].0;
i += 1;
}
out
};
const UNMAPPED: Profile = Profile {
ops: &["tables"],
prefixes: &[],
default_ops: &["tables"],
backend: false,
text_lints: TextLints::None,
};
const LANG_ENTRIES: &[(&str, Profile)] = &[
("ada", CODE_DASH),
("bash", CODE_HASH),
("c", CODE_SLASH),
("cc", CODE_SLASH),
("clj", CODE_SEMI),
("cljc", CODE_SEMI),
("conf", CODE_HASH),
("cpp", CODE_SLASH),
("cs", C_SHARP),
("dart", CODE_SLASH),
("el", CODE_SEMI),
("elm", CODE_DASH),
("erl", CODE_PERCENT),
("go", CODE_SLASH),
("h", CODE_SLASH),
("hpp", CODE_SLASH),
("hs", CODE_DASH),
("java", CODE_SLASH),
("jl", CODE_HASH),
("js", CODE_SLASH),
("kt", CODE_SLASH),
("lisp", CODE_SEMI),
("lua", CODE_DASH),
("m", CODE_PERCENT),
("markdown", MARKDOWN),
("md", MARKDOWN),
("mdx", MARKDOWN),
("mjs", CODE_SLASH),
("nim", CODE_HASH),
("php", CODE_SLASH),
("pl", CODE_HASH),
("py", PYTHON),
("pyi", PYTHON),
("r", CODE_HASH),
("rb", CODE_HASH),
("rs", RUST),
("scala", CODE_SLASH),
("scm", CODE_SEMI),
("sh", CODE_HASH),
("sql", CODE_DASH),
("swift", CODE_SLASH),
("tex", CODE_PERCENT),
("text", MARKDOWN),
("ts", CODE_SLASH),
("tsx", CODE_SLASH),
("txt", MARKDOWN),
("zig", CODE_SLASH),
("zsh", CODE_HASH),
];
const CODE_DASH: Profile = Profile {
ops: &["tables", "fences", "lints"],
prefixes: &["--"],
default_ops: &["tables", "lints"],
backend: false,
text_lints: TextLints::Lexicon,
};
const CODE_HASH: Profile = Profile {
ops: &["tables", "fences", "lints"],
prefixes: &["#"],
default_ops: &["tables", "lints"],
backend: false,
text_lints: TextLints::Lexicon,
};
const CODE_PERCENT: Profile = Profile {
ops: &["tables", "fences", "lints"],
prefixes: &["%"],
default_ops: &["tables", "lints"],
backend: false,
text_lints: TextLints::Lexicon,
};
const CODE_SEMI: Profile = Profile {
ops: &["tables", "fences", "lints"],
prefixes: &[";"],
default_ops: &["tables", "lints"],
backend: false,
text_lints: TextLints::Lexicon,
};
const CODE_SLASH: Profile = Profile {
ops: &["tables", "fences", "lints"],
prefixes: &["//"],
default_ops: &["tables", "lints"],
backend: false,
text_lints: TextLints::Lexicon,
};
const C_SHARP: Profile = Profile {
ops: &["tables", "fences", "reorder", "lints"],
prefixes: &["///", "//"],
default_ops: &["tables", "reorder", "lints"],
backend: true,
text_lints: TextLints::Ast,
};
const MARKDOWN: Profile = Profile {
ops: &["tables", "fences", "links", "lints"],
prefixes: &[],
default_ops: &["tables", "fences", "links", "lints"],
backend: false,
text_lints: TextLints::Prose,
};
const PYTHON: Profile = Profile {
ops: &["tables", "fences", "lints"],
prefixes: &["#"],
default_ops: &["tables", "lints"],
backend: true,
text_lints: TextLints::Ast,
};
const RUST: Profile = Profile {
ops: &["tables", "fences", "links", "reorder", "vis", "lints"],
prefixes: &["///", "//!"],
default_ops: &["tables", "fences", "links", "reorder", "vis", "lints"],
backend: true,
text_lints: TextLints::Ast,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Profile {
pub ops: &'static [&'static str],
pub prefixes: &'static [&'static str],
pub default_ops: &'static [&'static str],
pub backend: bool,
pub text_lints: TextLints,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TextLints {
Prose,
Ast,
Lexicon,
None,
}
impl Profile {
#[inline]
pub(crate) fn allows(&self, op: &str) -> bool {
self.ops.contains(&op)
}
pub(crate) fn op_enabled(
&self,
op: &str,
enabled: &Option<HashSet<String>>,
disabled: &HashSet<String>,
) -> bool {
match enabled {
Some(set) => set.contains(op) && self.allows(op),
None => self.default_ops.contains(&op) && !disabled.contains(op),
}
}
}
pub(crate) fn allowed_extensions<'a>(
config: Option<&'a crate::config::CompiledConfig>,
cli: &'a crate::Cli,
) -> Vec<&'a str> {
let mut exts: Vec<&str> = match config.filter(|c| !c.extension_override().is_empty()) {
Some(config) => config
.extension_override()
.iter()
.map(String::as_str)
.collect(),
None => DEFAULT_EXTENSIONS.to_vec(),
};
if let Some(config) = config {
exts.extend(config.extra_extensions().iter().map(String::as_str));
}
exts.extend(cli.extension.iter().map(String::as_str));
exts
}
#[inline]
pub(crate) fn profile_for(ext: &str) -> &'static Profile {
if let Ok(i) = LANG_ENTRIES.binary_search_by(|probe| cmp_ext(probe.0, ext)) {
return &LANG_ENTRIES[i].1;
}
if DATA_EXTENSIONS
.binary_search_by(|probe| cmp_ext(probe, ext))
.is_ok()
{
return &DATA;
}
&UNMAPPED
}
pub(crate) fn validate_extension(ext: &str) -> anyhow::Result<()> {
let shape_ok = !ext.is_empty()
&& !ext.starts_with('.')
&& !ext.contains(['.', '/', '\\'])
&& !ext.chars().any(char::is_whitespace);
if !shape_ok {
bail!(
"invalid extension `{ext}`: write it without a leading dot and with no inner \
dot, path separator, or whitespace"
);
}
Ok(())
}
#[inline]
fn cmp_ext(a: &str, b: &str) -> Ordering {
a.bytes()
.map(|byte| byte.to_ascii_lowercase())
.cmp(b.bytes().map(|byte| byte.to_ascii_lowercase()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn rules(names: &[&str]) -> HashSet<String> {
names.iter().map(|n| n.to_string()).collect()
}
const MD_FAMILY: &[&str] = &["md", "markdown", "txt", "text", "mdx"];
const CODE_FAMILIES: &[&[&str]] = &[
&[
"c", "h", "cpp", "cc", "hpp", "java", "js", "mjs", "ts", "tsx", "go", "swift", "kt",
"php", "dart", "scala", "zig",
],
&["rb", "sh", "bash", "zsh", "r", "pl", "jl", "nim", "conf"],
&["lua", "sql", "hs", "elm", "ada"],
&["el", "lisp", "clj", "cljc", "scm"],
&["tex", "erl", "m"],
];
fn assert_profile(ext: &str, profile: &Profile) {
assert_eq!(profile_for(ext), profile, "wrong tier for .{ext}");
}
#[test]
fn lookup_matches_extensions_ascii_case_insensitively() {
let cases = [
("MD", "md"),
("Rs", "rs"),
("CS", "cs"),
("PY", "py"),
("LUA", "lua"),
("JSON", "json"),
("ORG", "org"),
];
for (upper, lower) in cases {
assert_eq!(
profile_for(upper),
profile_for(lower),
".{upper} must resolve like .{lower}"
);
}
}
#[test]
fn data_formats_resolve_to_the_no_op_profile() {
for ext in DATA_EXTENSIONS {
assert_profile(ext, &DATA);
}
}
#[test]
fn registry_lists_exactly_the_approved_matrix() {
let mut expected: BTreeSet<&str> = MD_FAMILY.iter().copied().collect();
expected.extend(["rs", "cs", "py", "pyi"]);
for exts in CODE_FAMILIES {
expected.extend(exts.iter().copied());
}
let actual: BTreeSet<&str> = LANG_ENTRIES.iter().map(|(ext, _)| *ext).collect();
assert_eq!(actual, expected);
assert_eq!(
LANG_ENTRIES.len(),
actual.len(),
"duplicate table keys would collapse in the set"
);
}
#[test]
fn registry_tables_stay_sorted_for_binary_search() {
for pair in LANG_ENTRIES.windows(2) {
assert!(
cmp_ext(pair[0].0, pair[1].0) == Ordering::Less,
"`{}` must sort before `{}`",
pair[0].0,
pair[1].0
);
}
for pair in DATA_EXTENSIONS.windows(2) {
assert!(
cmp_ext(pair[0], pair[1]) == Ordering::Less,
"`{}` must sort before `{}`",
pair[0],
pair[1]
);
}
}
#[test]
fn ops_are_known_rules_with_defaults_a_subset() {
let mut all = vec![&UNMAPPED, &DATA];
all.extend(LANG_ENTRIES.iter().map(|(_, profile)| profile));
for profile in all {
for op in profile.ops {
assert!(
crate::config::KNOWN_FIX_OPS.contains(op),
"`{op}` is not a known rule name"
);
}
for op in profile.default_ops {
assert!(
profile.ops.contains(op),
"default op `{op}` must also be allowed"
);
}
}
}
#[test]
fn comment_markers_order_longest_first() {
let mut all = vec![&UNMAPPED, &DATA];
all.extend(LANG_ENTRIES.iter().map(|(_, profile)| profile));
for profile in all {
for (long, short) in profile
.prefixes
.iter()
.flat_map(|long| profile.prefixes.iter().map(move |short| (long, short)))
.filter(|(long, short)| long != short && long.starts_with(*short))
{
let long_idx = profile.prefixes.iter().position(|p| p == long).unwrap();
let short_idx = profile.prefixes.iter().position(|p| p == short).unwrap();
assert!(
long_idx < short_idx,
"`{long}` must precede `{short}` in {:?}",
profile.prefixes
);
}
}
}
#[test]
fn backend_column_matches_the_backend_registry() {
for (ext, profile) in LANG_ENTRIES {
let backend = rust_llm_tidy_lang::backend_for(ext);
assert_eq!(
profile.backend,
backend.is_some(),
".{ext}: profile column and backend registry disagree"
);
if profile.text_lints == TextLints::Ast {
assert!(
profile.backend,
".{ext}: the Ast text tier needs the backend column"
);
}
if let Some(backend) = backend {
for op in ["reorder", "vis"] {
assert_eq!(
profile.allows(op),
backend.ast_ops().contains(&op),
".{ext}: {op} availability disagrees"
);
}
assert!(
!backend.ast_ops().contains(&"lints") || profile.allows("lints"),
".{ext}: the backend's parser-driven lints must be allowed"
);
}
}
}
#[test]
fn op_enabled_combines_rule_selection_with_profile_ops() {
let none = None;
let empty = rules(&[]);
assert!(profile_for("py").op_enabled("tables", &none, &empty));
assert!(profile_for("py").op_enabled("lints", &none, &empty));
assert!(!profile_for("py").op_enabled("fences", &none, &empty));
for ext in MD_FAMILY.iter().chain(["rs"].iter()) {
for op in ["tables", "fences", "links"] {
assert!(
profile_for(ext).op_enabled(op, &none, &empty),
".{ext} must run {op} by default"
);
}
}
assert!(!profile_for("md").op_enabled("tables", &none, &rules(&["tables"])));
assert!(profile_for("md").op_enabled("fences", &none, &rules(&["tables"])));
assert!(!profile_for("json").op_enabled("tables", &none, &empty));
assert!(profile_for("py").op_enabled("fences", &Some(rules(&["fences"])), &empty));
assert!(!profile_for("py").op_enabled("links", &Some(rules(&["links"])), &empty));
assert!(!profile_for("md").op_enabled("reorder", &Some(rules(&["reorder"])), &empty));
for ext in ["js", "rb", "py", "sql", "el", "tex"] {
assert!(
profile_for(ext).op_enabled("lints", &Some(rules(&["lints"])), &empty),
".{ext}: an explicit include must reach the text checks"
);
assert!(
profile_for(ext).op_enabled("lints", &none, &empty),
".{ext}: lints must run in the default run"
);
}
}
#[test]
fn text_lint_tiers_cover_every_extension_exactly_once() {
for ext in MD_FAMILY {
assert_eq!(
profile_for(ext).text_lints,
TextLints::Prose,
".{ext} must measure whole-file prose"
);
}
for ext in ["rs", "cs", "py", "pyi"] {
assert_eq!(
profile_for(ext).text_lints,
TextLints::Ast,
".{ext} must source text regions from its backend"
);
}
for exts in CODE_FAMILIES {
for ext in *exts {
assert_eq!(
profile_for(ext).text_lints,
TextLints::Lexicon,
".{ext} must scan comments with the lexicon"
);
}
}
for (ext, profile) in LANG_ENTRIES {
match profile.text_lints {
TextLints::Prose => assert!(
MD_FAMILY.contains(ext),
".{ext} is outside the markdown family and must not be Prose"
),
TextLints::Ast => assert!(
*ext == "rs" || *ext == "cs" || *ext == "py" || *ext == "pyi",
".{ext} has no registered doc-region producer"
),
TextLints::Lexicon => assert!(
CODE_FAMILIES.iter().any(|exts| exts.contains(ext)),
".{ext} is outside the lexicon families"
),
TextLints::None => panic!(
".{ext} resolves to no producer tier; every registry \
extension must carry exactly one"
),
}
}
for ext in ["org", "", "json"] {
assert_eq!(
profile_for(ext).text_lints,
TextLints::None,
".{ext} must run no text checks"
);
}
}
#[test]
fn lexicon_tiers_match_the_lang_crate_lexicon() {
for (ext, profile) in LANG_ENTRIES {
assert_eq!(
profile.text_lints == TextLints::Lexicon,
rust_llm_tidy_lang::lexicon::covers(ext),
".{ext}: tier and lexicon coverage disagree"
);
}
for ext in ["org", "", "json"] {
assert!(
!rust_llm_tidy_lang::lexicon::covers(ext),
".{ext} must have no lexicon entry"
);
}
}
fn allowed_for(yaml: &str, cli_exts: &[&str]) -> Vec<String> {
static SEQ: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"rlt-langs-allow-{}-{}",
std::process::id(),
SEQ.fetch_add(1, core::sync::atomic::Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
let cfg_path = dir.join(".rust-llm-tidy.yml");
std::fs::write(&cfg_path, yaml).unwrap();
let config = crate::config::load_and_compile(&cfg_path).unwrap();
let mut args = vec!["rust-llm-tidy".to_string()];
for ext in cli_exts {
args.push("--extension".to_string());
args.push((*ext).to_string());
}
let cli = <crate::Cli as clap::Parser>::parse_from(args);
let allowed = allowed_extensions(Some(&config), &cli);
let _ = std::fs::remove_dir_all(&dir);
allowed.into_iter().map(String::from).collect()
}
#[test]
fn extensions_key_replaces_default_extensions() {
let allowed = allowed_for("extensions: [\"log\"]\n", &[]);
assert!(allowed.contains(&"log".to_string()), "log must be allowed");
for ext in DEFAULT_EXTENSIONS {
assert!(
!allowed.contains(&ext.to_string()),
".{ext} must be dropped by the replacement"
);
}
}
#[test]
fn extra_extensions_add_to_default_extensions() {
let allowed = allowed_for(
"extensions: []\nextra_extensions: [\"log\"]\n",
&["org", "MD"],
);
for ext in DEFAULT_EXTENSIONS {
assert!(allowed.contains(&ext.to_string()), ".{ext} missing");
}
for ext in ["log", "org", "MD"] {
assert!(allowed.contains(&ext.to_string()), "addition {ext} missing");
}
}
#[test]
fn extra_extensions_add_on_top_of_replaced_base() {
let allowed = allowed_for(
"extensions: [\"rs\"]\nextra_extensions: [\"log\"]\n",
&["org"],
);
let expected = ["rs", "log", "org"];
assert_eq!(allowed, expected.to_vec());
}
#[test]
fn validate_extension_rejects_unmatchable_shapes() {
for ext in ["rs", "MD", "c++"] {
assert!(validate_extension(ext).is_ok(), "`{ext}` should be valid");
}
for ext in ["", ".rs", "a.md", "src/rs", "a\\b", "a b"] {
assert!(
validate_extension(ext).is_err(),
"`{ext}` should be rejected"
);
}
}
}