use std::collections::BTreeSet;
use crate::ast::{
Claim, ClauseBody, Det, Predication, RelClause, Restr, RestrKind, Statement, Term,
};
use crate::parser::{self, line_col};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintNote {
pub code: &'static str,
pub message: String,
pub line: u32,
pub column: u32,
}
#[derive(Debug, Default)]
pub struct Linter {
seen_aliases: BTreeSet<String>,
introduced: BTreeSet<String>,
deontic_prefix_seen: bool,
norm_pred_seen: bool,
l7_fired: bool,
}
impl Linter {
pub fn new() -> Self {
Self::default()
}
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn lint(&mut self, input: &str) -> Vec<LintNote> {
let (statements, _errors) = parser::parse_text_with_errors(input);
let mut notes = Vec::new();
for (i, st) in statements.iter().enumerate() {
self.lint_statement_shape(input, st, &mut notes);
let mut w = Walk {
linter: self,
input,
notes: &mut notes,
};
w.claim(&st.claim, st.span.start);
if self.deontic_prefix_seen && self.norm_pred_seen && !self.l7_fired {
self.l7_fired = true;
push(
&mut notes,
input,
st.span.start,
"L7",
"this KB mixes must/may prefixes with the obligated_by()/permitted() \
predicate idiom — both are engine-faithful, but they don't chain \
with each other"
.to_string(),
);
}
if let Some(next) = statements.get(i + 1) {
self.lint_digit_split(input, st, next, &mut notes);
}
}
notes
}
fn lint_statement_shape(&self, input: &str, st: &Statement, notes: &mut Vec<LintNote>) {
fn disjunctive_consequent(c: &Claim) -> bool {
match c {
Claim::Or(..) => true,
Claim::Impl(_, rhs) => matches!(rhs.as_ref(), Claim::Or(..)),
_ => false,
}
}
let is_constraint = match &st.claim {
Claim::DetBlock {
det: Det::Every | Det::EveryThe,
body,
..
} => disjunctive_consequent(body),
Claim::Prenex { body, .. } => {
matches!(body.as_ref(), Claim::Impl(_, rhs) if matches!(rhs.as_ref(), Claim::Or(..)))
}
_ => false,
};
if is_constraint {
push(
notes,
input,
st.span.start,
"L5",
"registered as integrity constraint".to_string(),
);
}
}
fn lint_digit_split(
&self,
input: &str,
st: &Statement,
next: &Statement,
notes: &mut Vec<LintNote>,
) {
let bytes = input.as_bytes();
let dot = st.span.end.wrapping_sub(1);
let before = st.span.end.wrapping_sub(2);
let split = dot < bytes.len()
&& bytes[dot] == b'.'
&& before < dot
&& bytes[before].is_ascii_whitespace()
&& bytes.get(next.span.start).is_some_and(u8::is_ascii_digit);
if split {
push(
notes,
input,
dot,
"L9",
"this '.' is whitespace-separated from the claim and the next statement \
starts with a digit — the input splits into two statements here (a \
decimal like '2.5' must be written without spaces)"
.to_string(),
);
}
}
}
fn push(notes: &mut Vec<LintNote>, input: &str, at: usize, code: &'static str, message: String) {
let (line, column) = line_col(input, at);
notes.push(LintNote {
code,
message,
line,
column,
});
}
const DEONTIC_PREDICATES: &[&str] = &["obligated_by", "obliged", "permits", "permitted"];
fn introduces(det: Det) -> bool {
matches!(det, Det::Some | Det::Every | Det::Exactly(_))
}
struct Walk<'a> {
linter: &'a mut Linter,
input: &'a str,
notes: &'a mut Vec<LintNote>,
}
impl Walk<'_> {
fn claim(&mut self, claim: &Claim, at: usize) {
match claim {
Claim::Prenex { body, .. } => self.claim(body, at),
Claim::DetBlock { restr, body, .. } => {
self.restr_head_introduce(restr);
self.restr(restr);
self.claim(body, at);
}
Claim::Impl(a, b)
| Claim::Iff(a, b)
| Claim::Xor(a, b)
| Claim::Or(a, b)
| Claim::And(a, b) => {
self.claim(a, at);
self.claim(b, at);
}
Claim::Not(inner) => self.claim(inner, at),
Claim::Prefixed {
deontic,
tense,
atom,
} => {
if deontic.is_some() {
self.linter.deontic_prefix_seen = true;
}
if tense.is_some() && matches!(atom.as_ref(), Claim::Not(_)) {
push(
self.notes,
self.input,
at,
"L6",
"tense over a negated claim ('past ~P') reads as tense OUTSIDE \
the negation ('Past(not P)', 'it was not the case that P') — the \
one legal tense×NAF composition, and flavor-exact: a Past witness \
blocks it, a bare/future one does not (symmetric with 'past P')"
.to_string(),
);
}
self.claim(atom, at);
}
Claim::Equality(a, b) => {
self.term(a, at);
self.term(b, at);
}
Claim::Predication(p) => self.predication(p),
}
}
fn predication(&mut self, p: &Predication) {
let at = p.span.start;
self.seq_words(&p.seq, at);
let quants: Vec<String> = p
.args
.iter()
.filter_map(|a| match &a.term {
Term::Det { det, .. } => match det {
Det::Every | Det::EveryThe => Some("∀".to_string()),
Det::Some => Some("∃".to_string()),
Det::Exactly(n) | Det::ExactlyThe(n) => Some(format!("exactly-{n}")),
Det::The => None,
},
_ => None,
})
.collect();
if quants.len() >= 2 {
let order = quants.join(" before ");
push(
self.notes,
self.input,
at,
"L3",
format!(
"{} quantified arguments in one call — scope is written \
order ({order} here; reordering the arguments changes the \
meaning)",
quants.len()
),
);
}
for arg in &p.args {
self.term(&arg.term, arg.span.start);
}
for tag in &p.tags {
self.words(&tag.pred, tag.span.start);
self.term(&tag.term, tag.span.start);
}
}
fn term(&mut self, term: &Term, at: usize) {
match term {
Term::Name { rel_clauses, .. } => {
for rc in rel_clauses {
self.rel_clause(rc);
}
}
Term::Abstraction { body, .. } => self.claim(body, at),
Term::Det { det, restr } => {
if introduces(*det) {
self.restr_head_introduce(restr);
} else if *det == Det::The {
let head = restr_head(restr);
if !self.linter.introduced.contains(head) {
let msg = format!(
"'the {head}' names an opaque individual — NOT a \
quantifier, and no 'some/every {head}' statement \
introduced one; write 'some {head}' for \"a/some {head}\""
);
push(self.notes, self.input, restr.span.start, "L1", msg);
}
}
self.restr(restr);
}
_ => {}
}
}
fn restr_head_introduce(&mut self, restr: &Restr) {
self.linter.introduced.insert(restr_head(restr).to_string());
}
fn restr(&mut self, restr: &Restr) {
let at = restr.span.start;
match &restr.kind {
RestrKind::Seq { seq, linked_args } => {
self.seq_words(seq, at);
for arg in linked_args {
self.term(&arg.term, arg.span.start);
}
}
RestrKind::Selected { pred, .. } => self.words(std::slice::from_ref(pred), at),
}
for rc in &restr.rel_clauses {
self.rel_clause(rc);
}
}
fn rel_clause(&mut self, rc: &RelClause) {
let at = rc.span.start;
match &rc.body {
ClauseBody::Bare { seq, .. } => {
if seq.0.len() >= 2 {
push(
self.notes,
self.input,
at,
"L2",
format!(
"this bare clause body is ONE shared-event pair applied \
to 'it' (head '{}') — write the predicates joined with \
'&' for separate restrictor conjuncts",
seq.head_word()
),
);
}
self.seq_words(seq, at);
}
ClauseBody::Full(claim) => {
if let Claim::Equality(_, rhs) = claim.as_ref() {
let (attached, rhs_name) = match rhs {
Term::Name { name, rel_clauses } if !rel_clauses.is_empty() => {
(true, name.clone())
}
Term::Det { restr, .. } if !restr.rel_clauses.is_empty() => {
(true, restr_head(restr).to_string())
}
_ => (false, String::new()),
};
if attached {
push(
self.notes,
self.input,
at,
"L8",
format!(
"the trailing where/also clause attaches to '{rhs_name}' \
— the equality's right-hand term (innermost-wins, O9) — \
not to the outer restrictor; parenthesize the equality \
for outer attachment"
),
);
}
}
self.claim(claim, at);
}
}
}
fn seq_words(&mut self, seq: &crate::ast::PredSeq, at: usize) {
for unit in &seq.0 {
match unit {
crate::ast::PredUnit::Word(parts) => self.words(parts, at),
crate::ast::PredUnit::Group(inner) => self.seq_words(inner, at),
}
}
}
fn words(&mut self, parts: &[String], at: usize) {
if parts.len() > 1 {
if let Ok(info) = crate::resolve::lookup_compound(parts)
&& let crate::resolve::ResolvedEntry::Compound(entry) = info.entry
&& self.linter.seen_aliases.insert(info.surface.clone())
{
let msg = format!(
"{} \u{21a6} {}({})",
info.surface,
entry.relation,
entry.places.join(", ")
);
push(self.notes, self.input, at, "L4", msg);
}
return;
}
let word = &parts[0];
if DEONTIC_PREDICATES.contains(&word.as_str()) {
self.linter.norm_pred_seen = true;
}
if let Ok(info) = crate::resolve::lookup(word)
&& let crate::resolve::ResolvedEntry::Atomic(entry) = info.entry
&& let Some(swap) = entry.swap
&& self.linter.seen_aliases.insert(word.clone())
{
let msg = format!(
"{word} \u{21a6} {}\u{27e8}x1\u{2194}x{}\u{27e9}",
swap.base, swap.with
);
push(self.notes, self.input, at, "L4", msg);
}
}
}
pub fn lint_once(input: &str) -> Vec<LintNote> {
Linter::new().lint(input)
}
fn restr_head(restr: &Restr) -> &str {
match &restr.kind {
RestrKind::Seq { seq, .. } => seq.head_word(),
RestrKind::Selected { pred, .. } => pred,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn codes(input: &str) -> Vec<&'static str> {
lint_once(input).into_iter().map(|n| n.code).collect()
}
fn messages_for(input: &str, code: &str) -> Vec<String> {
lint_once(input)
.into_iter()
.filter(|n| n.code == code)
.map(|n| n.message)
.collect()
}
#[test]
fn l1_fires_on_unintroduced_the() {
let notes = lint_once("big(the dog).");
assert!(notes.iter().any(|n| n.code == "L1"), "{notes:?}");
}
#[test]
fn l1_quiet_after_some_introduction() {
assert!(!codes("eats(some dog).\nbig(the dog).").contains(&"L1"));
assert!(!codes("animal(every dog).\nbig(the dog).").contains(&"L1"));
}
#[test]
fn l1_order_matters_across_session_calls() {
let mut linter = Linter::new();
assert!(linter.lint("big(the dog).").iter().any(|n| n.code == "L1"));
linter.lint("eats(some dog).");
assert!(!linter.lint("big(the dog).").iter().any(|n| n.code == "L1"));
linter.reset();
assert!(linter.lint("big(the dog).").iter().any(|n| n.code == "L1"));
}
#[test]
fn lint_messages_carry_no_lojban_spelling() {
const LOJBAN: [&str; 4] = ["le", "lo", "du", "zo'e"];
let inputs = [
"big(the dog).", "beautiful(every person where big fast).", "eats(some dog, every cat).", "metabolized_by(Adam, Betis).", "computer+user(me).", "every dog $d: big($d) | fast($d).", "past ~eats(Adam).", "must eats(Adam).\npermitted(Adam).", "beautiful(every person where it = Boss also big).", "$x = 2 .5 = $y.", include_str!("../tests/acceptance.nibli"),
];
let mut saw_l1 = false;
for input in inputs {
for note in lint_once(input) {
saw_l1 |= note.code == "L1";
for word in note
.message
.split(|c: char| !c.is_ascii_alphanumeric() && c != '\'')
{
assert!(
!LOJBAN.contains(&word),
"{}: Lojban spelling {word:?} leaked into a user-facing \
lint note: {}",
note.code,
note.message
);
}
}
}
assert!(saw_l1, "L1 must fire over these inputs");
}
#[test]
fn l2_fires_on_bare_multiword_pair_body() {
assert!(codes("beautiful(every person where big fast).").contains(&"L2"));
}
#[test]
fn l2_quiet_on_single_word_and_on_conjunction() {
assert!(!codes("beautiful(every person where big).").contains(&"L2"));
assert!(!codes("beautiful(every person where ~cat).").contains(&"L2"));
assert!(!codes("beautiful(every person where big(it) & fast(it)).").contains(&"L2"));
}
#[test]
fn l3_fires_on_two_quantified_args() {
assert!(codes("eats(some dog, every cat).").contains(&"L3"));
}
#[test]
fn l3_quiet_on_one_quantified_arg() {
assert!(!codes("eats(Adam, every cat).").contains(&"L3"));
}
#[test]
fn l3_note_reports_actual_scope_order() {
let msg = messages_for("likes(every dog, some cat).", "L3")
.into_iter()
.next()
.expect("L3 fires");
assert!(msg.contains("∀ before ∃"), "{msg}");
assert!(!msg.contains("∃ before ∀"), "{msg}");
let msg = messages_for("eats(some dog, every cat).", "L3")
.into_iter()
.next()
.expect("L3 fires");
assert!(msg.contains("∃ before ∀"), "{msg}");
}
#[test]
fn l4_echoes_converted_alias_swap_once() {
let mut linter = Linter::new();
let notes = linter.lint("dog(Adam).\nmetabolized_by(Adam, Betis).");
let l4: Vec<_> = notes.iter().filter(|n| n.code == "L4").collect();
assert!(
l4.iter()
.any(|n| n.message == "metabolized_by \u{21a6} cuts\u{27e8}x1\u{2194}x2\u{27e9}"),
"{l4:?}"
);
assert_eq!(l4.len(), 1, "plain `dog` must not echo: {l4:?}");
assert!(
!linter
.lint("metabolized_by(Betis, Kim).")
.iter()
.any(|n| n.code == "L4"),
"a converted alias must echo only on first use per session"
);
}
#[test]
fn l4_echoes_compound_place_structure_once() {
let mut linter = Linter::new();
let notes = linter.lint("computer+user(me).");
let l4: Vec<_> = notes.iter().filter(|n| n.code == "L4").collect();
assert_eq!(l4.len(), 1, "{l4:?}");
assert_eq!(
l4[0].message,
"computer+user \u{21a6} computer_user(user, computer, purpose)"
);
assert!(
!linter
.lint("computer+user(you).")
.iter()
.any(|n| n.code == "L4"),
"a compound must echo only on first use per session"
);
}
#[test]
fn l4_quiet_on_identity_gismu() {
assert!(!codes("gerku(Adam).").contains(&"L4"));
}
#[test]
fn l5_fires_on_disjunctive_universal_block() {
let msgs = messages_for("every dog $d: big($d) | fast($d).", "L5");
assert_eq!(msgs, vec!["registered as integrity constraint".to_string()]);
}
#[test]
fn l5_quiet_on_plain_rule() {
assert!(!codes("animal(every dog).").contains(&"L5"));
assert!(!codes("every dog $d: big($d) & fast($d).").contains(&"L5"));
}
#[test]
fn l6_fires_on_past_not() {
assert!(codes("past ~eats(Adam).").contains(&"L6"));
}
#[test]
fn l6_quiet_on_plain_tense_and_plain_negation() {
assert!(!codes("past eats(Adam).").contains(&"L6"));
assert!(!codes("~eats(Adam).").contains(&"L6"));
}
#[test]
fn l7_fires_once_when_both_idioms_appear() {
let mut linter = Linter::new();
assert!(
!linter
.lint("must eats(Adam).")
.iter()
.any(|n| n.code == "L7")
);
let second = linter.lint("permitted(Adam).");
assert_eq!(second.iter().filter(|n| n.code == "L7").count(), 1);
assert!(
!linter
.lint("may eats(Betis).")
.iter()
.any(|n| n.code == "L7")
);
}
#[test]
fn deontic_predicates_match_the_corpus_family() {
let family: Vec<&str> = nibli_lexicon::corpus::corpus_entries()
.filter(|e| matches!(e.source_gismu, "bilga" | "curmi"))
.map(|e| e.name)
.collect();
assert_eq!(family, DEONTIC_PREDICATES);
}
#[test]
fn l7_quiet_on_either_idiom_alone() {
assert!(!codes("must eats(Adam).\nmay eats(Betis).").contains(&"L7"));
assert!(!codes("permitted(Adam).\nobligated_by(Adam).").contains(&"L7"));
}
#[test]
fn l8_fires_on_equality_rhs_rel_clause() {
let notes = lint_once("beautiful(every person where it = Boss also big).");
let l8: Vec<_> = notes.iter().filter(|n| n.code == "L8").collect();
assert_eq!(l8.len(), 1, "{notes:?}");
assert!(l8[0].message.contains("'Boss'"), "{}", l8[0].message);
}
#[test]
fn l8_quiet_without_rhs_clause() {
assert!(!codes("beautiful(every person where it = Boss).").contains(&"L8"));
}
#[test]
fn l9_fires_on_whitespace_dot_before_digit_statement() {
let notes = lint_once("$x = 2 .5 = $y.");
assert!(notes.iter().any(|n| n.code == "L9"), "{notes:?}");
}
#[test]
fn l9_quiet_on_adjacent_dot_and_non_digit_follow() {
assert!(!codes("$x = 2.5.").contains(&"L9"));
assert!(!codes("$x = 2 .\ndog(Adam).").contains(&"L9"));
}
#[test]
fn acceptance_corpus_lints_without_panicking() {
let corpus = include_str!("../tests/acceptance.nibli");
let notes = lint_once(corpus);
assert!(
notes.iter().any(|n| n.code == "L4"),
"expected converted-alias echoes over the acceptance corpus"
);
}
}