use std::collections::BTreeMap;
use std::collections::BTreeSet;
use crate::domain::config::is_valid_rule_name;
const MARKER: &str = "llmlint:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Problem {
pub line: usize,
pub message: String,
}
#[derive(Debug, Clone, Copy)]
enum Kind {
Line,
File,
BlockStart,
BlockEnd,
}
impl Kind {
fn keyword(self) -> &'static str {
match self {
Kind::Line => "ignore",
Kind::File => "ignore-file",
Kind::BlockStart => "ignore-block",
Kind::BlockEnd => "ignore-end",
}
}
fn needs_reason(self) -> bool {
!matches!(self, Kind::BlockEnd)
}
}
pub fn validate(text: &str, known_rules: &BTreeSet<&str>) -> Vec<Problem> {
let mut problems = Vec::new();
let mut open: BTreeMap<&str, usize> = BTreeMap::new();
for (idx, raw) in text.lines().enumerate() {
let line = idx + 1;
let Some(pos) = raw.find(MARKER) else {
continue;
};
let after = raw[pos + MARKER.len()..].trim_start();
let Some((kind, body)) = classify(after) else {
continue;
};
let (messages, rules) = check_body(body, known_rules, kind);
for message in messages {
problems.push(Problem { line, message });
}
match kind {
Kind::Line | Kind::File => {}
Kind::BlockStart => {
for r in rules {
if let Some(prev) = open.get(r) {
problems.push(Problem {
line,
message: format!(
"rule {r:?} already has an open ignore-block at line {prev}; \
close it with `ignore-end[{r}]` before opening another"
),
});
} else {
open.insert(r, line);
}
}
}
Kind::BlockEnd => {
for r in rules {
if open.remove(r).is_none() {
problems.push(Problem {
line,
message: format!(
"ignore-end for rule {r:?} with no open ignore-block above it"
),
});
}
}
}
}
}
for (r, opened) in open {
problems.push(Problem {
line: opened,
message: format!(
"unclosed ignore-block for rule {r:?}; add a matching `llmlint: ignore-end[{r}]`"
),
});
}
problems.sort_by_key(|p| p.line);
problems
}
#[derive(Debug, Clone, Default)]
pub struct Suppressions {
file_scoped: BTreeSet<String>,
ranges: BTreeMap<String, Vec<(usize, usize)>>,
}
impl Suppressions {
pub fn covers(&self, rule: &str, line: Option<u64>) -> bool {
if self.file_scoped.contains(rule) {
return true;
}
let Some(line) = line else {
return false;
};
let line = line as usize;
self.ranges
.get(rule)
.is_some_and(|rs| rs.iter().any(|(s, e)| *s <= line && line <= *e))
}
pub fn is_empty(&self) -> bool {
self.file_scoped.is_empty() && self.ranges.is_empty()
}
pub fn is_file_scoped(&self, rule: &str) -> bool {
self.file_scoped.contains(rule)
}
}
pub fn suppressions(text: &str, known: &BTreeSet<&str>) -> Suppressions {
let mut out = Suppressions::default();
let mut open: BTreeMap<String, usize> = BTreeMap::new();
let mut last_line = 0usize;
for (idx, raw) in text.lines().enumerate() {
let line = idx + 1;
last_line = line;
let Some(pos) = raw.find(MARKER) else {
continue;
};
let after = raw[pos + MARKER.len()..].trim_start();
let Some((kind, body)) = classify(after) else {
continue;
};
let (_problems, rules) = check_body(body, known, kind);
match kind {
Kind::Line => {
for r in rules {
out.ranges
.entry(r.to_string())
.or_default()
.push((line, line + 1));
}
}
Kind::File => {
for r in rules {
out.file_scoped.insert(r.to_string());
}
}
Kind::BlockStart => {
for r in rules {
open.entry(r.to_string()).or_insert(line);
}
}
Kind::BlockEnd => {
for r in rules {
if let Some(start) = open.remove(r) {
out.ranges
.entry(r.to_string())
.or_default()
.push((start, line));
}
}
}
}
}
for (r, start) in open {
out.ranges
.entry(r)
.or_default()
.push((start, last_line.max(start)));
}
out
}
fn classify(after: &str) -> Option<(Kind, &str)> {
for (keyword, kind) in [
("ignore-file", Kind::File),
("ignore-block", Kind::BlockStart),
("ignore-end", Kind::BlockEnd),
("ignore", Kind::Line),
] {
if let Some(body) = strip_keyword(after, keyword) {
return Some((kind, body));
}
}
None
}
fn strip_keyword<'a>(s: &'a str, keyword: &str) -> Option<&'a str> {
let rest = s.strip_prefix(keyword)?;
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_whitespace() || c == '[' => Some(rest),
_ => None,
}
}
fn check_body<'a>(
body: &'a str,
known_rules: &BTreeSet<&str>,
kind: Kind,
) -> (Vec<String>, Vec<&'a str>) {
let mut issues = Vec::new();
let mut valid = Vec::new();
let body = body.trim_start();
let keyword = kind.keyword();
let example = if kind.needs_reason() {
format!("`{keyword}[rule_name] <reason>`")
} else {
format!("`{keyword}[rule_name]`")
};
if !body.starts_with('[') {
issues.push(format!("name the rule(s) in brackets, e.g. {example}"));
return (issues, valid);
}
let Some(close) = body.find(']') else {
issues.push("unterminated rule list: add a closing `]` after the rule name(s)".into());
return (issues, valid);
};
let inside = &body[1..close];
let rules: Vec<&str> = inside
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if rules.is_empty() {
issues.push("name at least one rule inside the brackets".into());
}
for r in &rules {
if !is_valid_rule_name(r) {
issues.push(format!(
"{r:?} is not a valid rule name (letters, digits, underscore; \
must start with a letter)"
));
} else if !known_rules.contains(r) {
issues.push(format!(
"unknown rule {r:?}; configured rules: {}",
available(known_rules)
));
} else {
valid.push(*r);
}
}
if kind.needs_reason() && reason_of(&body[close + 1..]).is_empty() {
issues
.push("give a reason after the brackets explaining why the rule(s) are ignored".into());
}
(issues, valid)
}
fn reason_of(after_bracket: &str) -> &str {
let mut reason = after_bracket.trim();
loop {
let stripped = reason
.strip_suffix("*/")
.or_else(|| reason.strip_suffix("-->"))
.map(str::trim_end);
match stripped {
Some(s) if s.len() != reason.len() => reason = s,
_ => break,
}
}
reason
}
fn available(known_rules: &BTreeSet<&str>) -> String {
if known_rules.is_empty() {
"(none)".to_string()
} else {
known_rules.iter().copied().collect::<Vec<_>>().join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn known<'a>(names: &[&'a str]) -> BTreeSet<&'a str> {
names.iter().copied().collect()
}
fn messages(text: &str, names: &[&str]) -> Vec<String> {
validate(text, &known(names))
.into_iter()
.map(|p| format!("{}:{}", p.line, p.message))
.collect()
}
#[test]
fn well_formed_line_and_file_directives_have_no_problems() {
let text = "// llmlint: ignore[no_todo] tracked in JIRA-1\n\
/* llmlint: ignore-file[no_todo, no_sql] generated */\n\
# llmlint: ignore[no_sql] one-off migration script\n";
assert!(validate(text, &known(&["no_todo", "no_sql"])).is_empty());
}
#[test]
fn prose_mentioning_the_marker_is_not_a_directive() {
let text = "// see llmlint: docs for the ignore feature\n\
// we llmlint: ignored this once (prose)\n\
// llmlint: ignore-foo[x] not a keyword\n";
assert!(validate(text, &known(&["x"])).is_empty());
}
#[test]
fn bare_ignore_without_brackets_is_rejected() {
let msgs = messages("// llmlint: ignore please\n", &["r"]);
assert_eq!(msgs.len(), 1);
assert!(msgs[0].starts_with("1:"));
assert!(msgs[0].contains("brackets"), "got: {msgs:?}");
}
#[test]
fn empty_bracket_list_is_rejected() {
let msgs = messages("// llmlint: ignore[] reason\n", &["r"]);
assert!(msgs.iter().any(|m| m.contains("at least one rule")));
}
#[test]
fn unknown_rule_is_rejected_and_lists_configured() {
let msgs = messages("// llmlint: ignore[typo] reason\n", &["alpha", "beta"]);
assert_eq!(msgs.len(), 1);
assert!(msgs[0].contains("unknown rule"), "got: {msgs:?}");
assert!(msgs[0].contains("alpha, beta"), "got: {msgs:?}");
}
#[test]
fn invalid_rule_name_is_rejected() {
let msgs = messages("// llmlint: ignore[bad-name] reason\n", &["bad"]);
assert!(msgs.iter().any(|m| m.contains("not a valid rule name")));
}
#[test]
fn missing_reason_is_rejected_including_block_comment_close() {
let msgs = messages("// llmlint: ignore[r]\n/* llmlint: ignore[r] */\n", &["r"]);
assert_eq!(msgs.len(), 2, "got: {msgs:?}");
assert!(msgs.iter().all(|m| m.contains("give a reason")));
assert!(msgs[0].starts_with("1:"));
assert!(msgs[1].starts_with("2:"));
}
#[test]
fn reason_before_block_comment_terminator_is_accepted() {
let text = "/* llmlint: ignore[r] legacy shim, see #42 */\n\
<!-- llmlint: ignore-file[r] vendored doc -->\n";
assert!(validate(text, &known(&["r"])).is_empty());
}
#[test]
fn unterminated_bracket_list_is_rejected() {
let msgs = messages("// llmlint: ignore[r reason\n", &["r"]);
assert!(msgs.iter().any(|m| m.contains("unterminated")));
}
#[test]
fn multiple_problems_on_one_directive_are_all_reported() {
let msgs = messages("// llmlint: ignore[ghost]\n", &["real"]);
assert_eq!(msgs.len(), 2, "got: {msgs:?}");
assert!(msgs.iter().any(|m| m.contains("unknown rule")));
assert!(msgs.iter().any(|m| m.contains("give a reason")));
}
#[test]
fn available_says_none_when_no_rules_configured() {
let msgs = messages("// llmlint: ignore[x] reason\n", &[]);
assert!(msgs[0].contains("(none)"), "got: {msgs:?}");
}
#[test]
fn trailing_directive_after_real_code_is_validated() {
let msgs = messages("let x = todo(); // llmlint: ignore[r]\n", &["r"]);
assert!(msgs.iter().any(|m| m.contains("give a reason")));
}
#[test]
fn matched_block_open_and_close_is_accepted() {
let text = "// llmlint: ignore-block[r] legacy region, see #7\n\
fn f() {}\n\
// llmlint: ignore-end[r]\n";
assert!(validate(text, &known(&["r"])).is_empty());
}
#[test]
fn block_open_without_reason_is_rejected() {
let msgs = messages(
"// llmlint: ignore-block[r]\n// llmlint: ignore-end[r]\n",
&["r"],
);
assert_eq!(msgs.len(), 1, "got: {msgs:?}");
assert!(msgs[0].contains("give a reason"), "got: {msgs:?}");
}
#[test]
fn block_end_needs_no_reason() {
let text = "// llmlint: ignore-block[r] reason\n// llmlint: ignore-end[r]\n";
assert!(validate(text, &known(&["r"])).is_empty());
}
#[test]
fn unclosed_block_is_reported_at_its_opening_line() {
let msgs = messages(
"// code\n// llmlint: ignore-block[r] never closed\nmore code\n",
&["r"],
);
assert_eq!(msgs.len(), 1, "got: {msgs:?}");
assert!(msgs[0].starts_with("2:"), "got: {msgs:?}");
assert!(msgs[0].contains("unclosed ignore-block"), "got: {msgs:?}");
}
#[test]
fn block_end_without_a_matching_open_is_rejected() {
let msgs = messages("// llmlint: ignore-end[r]\n", &["r"]);
assert_eq!(msgs.len(), 1, "got: {msgs:?}");
assert!(msgs[0].starts_with("1:"));
assert!(msgs[0].contains("no open ignore-block"), "got: {msgs:?}");
}
#[test]
fn reopening_an_open_block_for_the_same_rule_is_rejected() {
let msgs = messages(
"// llmlint: ignore-block[r] first\n\
// llmlint: ignore-block[r] second\n\
// llmlint: ignore-end[r]\n",
&["r"],
);
assert_eq!(msgs.len(), 1, "got: {msgs:?}");
assert!(msgs[0].starts_with("2:"), "got: {msgs:?}");
assert!(
msgs[0].contains("already has an open ignore-block at line 1"),
"got: {msgs:?}"
);
}
#[test]
fn two_rules_opened_together_can_close_at_different_lines() {
let text = "// llmlint: ignore-block[a, b] both exempt here\n\
fn f() {}\n\
// llmlint: ignore-end[a]\n\
fn g() {}\n\
// llmlint: ignore-end[b]\n";
assert!(validate(text, &known(&["a", "b"])).is_empty());
}
#[test]
fn one_of_two_opened_rules_left_unclosed_is_reported() {
let text = "// llmlint: ignore-block[a, b] reason\n\
// llmlint: ignore-end[a]\n";
let msgs = messages(text, &["a", "b"]);
assert_eq!(msgs.len(), 1, "got: {msgs:?}");
assert!(msgs[0].starts_with("1:"), "got: {msgs:?}");
assert!(msgs[0].contains("unclosed ignore-block"), "got: {msgs:?}");
assert!(msgs[0].contains("\"b\""), "got: {msgs:?}");
}
#[test]
fn overlapping_blocks_for_distinct_rules_are_accepted() {
let text = "// llmlint: ignore-block[a] outer\n\
// llmlint: ignore-block[b] inner\n\
// llmlint: ignore-end[a]\n\
// llmlint: ignore-end[b]\n";
assert!(validate(text, &known(&["a", "b"])).is_empty());
}
#[test]
fn block_directives_naming_unknown_rules_are_rejected() {
let msgs = messages(
"// llmlint: ignore-block[ghost] reason\n// llmlint: ignore-end[ghost]\n",
&["real"],
);
assert_eq!(msgs.len(), 2, "got: {msgs:?}");
assert!(
msgs.iter().all(|m| m.contains("unknown rule")),
"got: {msgs:?}"
);
}
#[test]
fn block_problems_are_reported_in_line_order() {
let msgs = messages(
"// llmlint: ignore-end[a]\n// llmlint: ignore-block[b] reason\n",
&["a", "b"],
);
assert_eq!(msgs.len(), 2, "got: {msgs:?}");
assert!(msgs[0].starts_with("1:"), "got: {msgs:?}");
assert!(msgs[1].starts_with("2:"), "got: {msgs:?}");
}
#[test]
fn suppressions_line_covers_own_line_and_the_one_below() {
let s = suppressions("// llmlint: ignore[r] reason\ncode\nmore\n", &known(&["r"]));
assert!(s.covers("r", Some(1)));
assert!(s.covers("r", Some(2)));
assert!(!s.covers("r", Some(3)));
assert!(!s.covers("r", None));
assert!(!s.covers("other", Some(1)));
}
#[test]
fn suppressions_file_scope_covers_every_line_including_unlocated() {
let s = suppressions("/* llmlint: ignore-file[r] generated */\n", &known(&["r"]));
assert!(s.covers("r", Some(999)));
assert!(s.covers("r", None));
assert!(!s.covers("nope", Some(1)));
}
#[test]
fn suppressions_block_covers_open_through_close_inclusive() {
let text = "// llmlint: ignore-block[r] reason\ncode\ncode\n// llmlint: ignore-end[r]\n";
let s = suppressions(text, &known(&["r"]));
for l in 1..=4 {
assert!(s.covers("r", Some(l)), "line {l} should be covered");
}
assert!(!s.covers("r", Some(5)));
}
#[test]
fn suppressions_independent_per_rule_blocks() {
let text = "// llmlint: ignore-block[a, b] both\ncode\n// llmlint: ignore-end[a]\n\
code\n// llmlint: ignore-end[b]\n";
let s = suppressions(text, &known(&["a", "b"]));
assert!(s.covers("a", Some(2)));
assert!(!s.covers("a", Some(4)));
assert!(s.covers("b", Some(4)));
}
#[test]
fn suppressions_empty_when_no_directives() {
assert!(suppressions("just code\n", &known(&["r"])).is_empty());
}
#[test]
fn is_file_scoped_reports_only_whole_file_ignores() {
let text = "/* llmlint: ignore-file[a] generated */\n\
// llmlint: ignore[b] one-off\n";
let s = suppressions(text, &known(&["a", "b"]));
assert!(s.is_file_scoped("a"));
assert!(!s.is_file_scoped("b"));
assert!(!s.is_file_scoped("c"));
}
}