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,
}
pub fn validate(text: &str, known_rules: &BTreeSet<&str>) -> Vec<Problem> {
let mut problems = Vec::new();
for (idx, raw) in text.lines().enumerate() {
let Some(pos) = raw.find(MARKER) else {
continue;
};
let after = raw[pos + MARKER.len()..].trim_start();
let body = strip_keyword(after, "ignore-file").or_else(|| strip_keyword(after, "ignore"));
let Some(body) = body else {
continue;
};
for message in check_body(body, known_rules) {
problems.push(Problem {
line: idx + 1,
message,
});
}
}
problems
}
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(body: &str, known_rules: &BTreeSet<&str>) -> Vec<String> {
let mut issues = Vec::new();
let body = body.trim_start();
if !body.starts_with('[') {
issues.push(
"name the rule(s) to ignore in brackets, e.g. `ignore[rule_name] <reason>`".into(),
);
return issues;
}
let Some(close) = body.find(']') else {
issues.push("unterminated rule list: add a closing `]` after the rule name(s)".into());
return issues;
};
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 to ignore 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)
));
}
}
if reason_of(&body[close + 1..]).is_empty() {
issues
.push("give a reason after the brackets explaining why the rule(s) are ignored".into());
}
issues
}
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")));
}
}