Skip to main content

patch_prolog_frontend/
lint.rs

1//! Static lint: calls to predicates that are defined nowhere.
2//!
3//! patch-prolog is a whole-program compiler with no `assert`/`retract`, so
4//! the complete predicate set is known at compile time. A direct body goal
5//! that calls a predicate with no clauses, no `:- dynamic` declaration, and
6//! which is not a builtin or stdlib predicate can never succeed — at
7//! runtime it raises `existence_error(procedure, F/A)`.
8//!
9//! ISO requires that error to stay a *catchable runtime* condition (so
10//! `catch/3` of an undefined call works), which is why callers treat this
11//! as a **warning** by default and only promote it to an error on request
12//! (`plgc … --deny-undefined`).
13//!
14//! Lives in the frontend so BOTH the compiler and the LSP can run it
15//! without pulling in codegen or the runtime. Only *direct,
16//! statically-resolvable* calls are checked; runtime-built goals (a
17//! variable goal, `call/N` with N>1) are left to the runtime.
18
19use crate::ProgramDirectives;
20use plg_shared::{AtomId, BUILTINS, Clause, StringInterner, Term};
21use std::collections::BTreeSet;
22
23/// An undefined-predicate reference, rendered for display.
24pub struct Undefined {
25    pub callee: (String, usize),
26    pub caller: (String, usize),
27    /// Closest defined/builtin name of the same arity, if one is near.
28    pub suggestion: Option<String>,
29}
30
31/// Collect every `(caller, callee)` where a clause body directly calls a
32/// predicate that is defined nowhere. Deduplicated and deterministically
33/// ordered. `clauses`/`directives` must be the FULL compilation unit
34/// (stdlib included) so stdlib calls are not flagged.
35pub fn undefined_calls(
36    clauses: &[Clause],
37    directives: &ProgramDirectives,
38    interner: &StringInterner,
39) -> Vec<Undefined> {
40    let mut defined: BTreeSet<(AtomId, usize)> = BTreeSet::new();
41    for c in clauses {
42        if let Some(k) = c.head.functor_arity() {
43            defined.insert(k);
44        }
45    }
46    for &(f, a) in &directives.dynamic {
47        defined.insert((f, a));
48    }
49
50    // dedup key: (caller_name, caller_arity, callee_name, callee_arity)
51    let mut hits: BTreeSet<(String, usize, String, usize)> = BTreeSet::new();
52    for c in clauses {
53        let Some((cf, ca)) = c.head.functor_arity() else {
54            continue;
55        };
56        let caller = (interner.resolve(cf).to_string(), ca);
57        for goal in &c.body {
58            collect(goal, &defined, interner, &mut |fid, arity| {
59                hits.insert((
60                    caller.0.clone(),
61                    caller.1,
62                    interner.resolve(fid).to_string(),
63                    arity,
64                ));
65            });
66        }
67    }
68
69    let defined_names: Vec<(String, usize)> = defined
70        .iter()
71        .map(|&(f, a)| (interner.resolve(f).to_string(), a))
72        .collect();
73    hits.into_iter()
74        .map(|(cn, ca, en, ea)| Undefined {
75            suggestion: suggest(&en, ea, &defined_names),
76            callee: (en, ea),
77            caller: (cn, ca),
78        })
79        .collect()
80}
81
82/// Format a lint as `<predicate> ... [— did you mean <x>?]`.
83pub fn message(u: &Undefined) -> String {
84    let mut m = format!(
85        "undefined predicate {}/{} called from {}/{}",
86        u.callee.0, u.callee.1, u.caller.0, u.caller.1
87    );
88    if let Some(s) = &u.suggestion {
89        m.push_str(&format!(" — did you mean {s}?"));
90    }
91    m
92}
93
94/// Walk a goal term, invoking `emit(fid, arity)` for each direct call to a
95/// predicate not in `defined` and not a recognized builtin. Recurses into
96/// the goal positions of control constructs and meta-predicates.
97fn collect(
98    goal: &Term,
99    defined: &BTreeSet<(AtomId, usize)>,
100    interner: &StringInterner,
101    emit: &mut impl FnMut(AtomId, usize),
102) {
103    let Some((fid, arity)) = goal.functor_arity() else {
104        return; // variable goal / non-callable: runtime's problem
105    };
106    let name = interner.resolve(fid);
107    let args: &[Term] = match goal {
108        Term::Compound { args, .. } => args,
109        _ => &[],
110    };
111    match (name, arity) {
112        (",", 2) | (";", 2) | ("->", 2) => {
113            collect(&args[0], defined, interner, emit);
114            collect(&args[1], defined, interner, emit);
115        }
116        ("\\+", 1) | ("once", 1) => collect(&args[0], defined, interner, emit),
117        // `call(G)` checks G; `call(G, Extra…)` can't be resolved to a
118        // fixed arity statically — leave it to the runtime.
119        ("call", 1) => collect(&args[0], defined, interner, emit),
120        ("call", _) => {}
121        ("findall", 3) => collect(&args[1], defined, interner, emit),
122        ("catch", 3) => {
123            collect(&args[0], defined, interner, emit);
124            collect(&args[2], defined, interner, emit);
125        }
126        // Any other recognized builtin: its args are data, not goals.
127        _ if is_builtin(name, arity) => {}
128        // A real user-predicate call.
129        _ if !defined.contains(&(fid, arity)) => emit(fid, arity),
130        _ => {}
131    }
132}
133
134fn is_builtin(name: &str, arity: usize) -> bool {
135    BUILTINS
136        .iter()
137        .any(|b| b.name == name && b.arity as usize == arity)
138}
139
140/// Nearest same-arity defined or builtin name within edit distance 2.
141fn suggest(name: &str, arity: usize, defined: &[(String, usize)]) -> Option<String> {
142    let candidates = defined
143        .iter()
144        .filter(|(_, a)| *a == arity)
145        .map(|(n, a)| (n.as_str(), *a))
146        .chain(
147            BUILTINS
148                .iter()
149                .filter(|b| b.arity as usize == arity)
150                .map(|b| (b.name, b.arity as usize)),
151        );
152    let mut best: Option<(usize, String)> = None;
153    for (cand, a) in candidates {
154        if cand == name {
155            continue;
156        }
157        let d = edit_distance(name, cand);
158        if d <= 2 && best.as_ref().is_none_or(|(bd, _)| d < *bd) {
159            best = Some((d, format!("{cand}/{a}")));
160        }
161    }
162    best.map(|(_, s)| s)
163}
164
165/// Levenshtein distance (small inputs; the two-row DP is plenty).
166fn edit_distance(a: &str, b: &str) -> usize {
167    let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
168    let mut prev: Vec<usize> = (0..=b.len()).collect();
169    let mut cur = vec![0usize; b.len() + 1];
170    for (i, &ca) in a.iter().enumerate() {
171        cur[0] = i + 1;
172        for (j, &cb) in b.iter().enumerate() {
173            let cost = if ca == cb { 0 } else { 1 };
174            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
175        }
176        std::mem::swap(&mut prev, &mut cur);
177    }
178    prev[b.len()]
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::Parser;
185
186    /// Undefined callees (name/arity) for a self-contained program.
187    fn callees(src: &str) -> Vec<(String, usize)> {
188        let mut interner = StringInterner::new();
189        let (clauses, directives) =
190            Parser::parse_program_with_directives(src, &mut interner).unwrap();
191        let mut v: Vec<_> = undefined_calls(&clauses, &directives, &interner)
192            .into_iter()
193            .map(|u| u.callee)
194            .collect();
195        v.sort();
196        v
197    }
198
199    #[test]
200    fn flags_a_direct_undefined_call() {
201        assert_eq!(callees("a :- b.\n"), vec![("b".into(), 0)]);
202    }
203
204    #[test]
205    fn defined_predicate_is_not_flagged() {
206        assert!(callees("a :- b.\nb.\n").is_empty());
207    }
208
209    #[test]
210    fn builtins_and_inline_ops_not_flagged() {
211        assert!(callees("a(X) :- X is 1 + 2, X > 0, write(X).\n").is_empty());
212    }
213
214    #[test]
215    fn dynamic_declaration_suppresses() {
216        assert!(callees(":- dynamic(b/1).\na(X) :- b(X).\n").is_empty());
217    }
218
219    #[test]
220    fn recurses_into_disjunction_and_naf() {
221        assert_eq!(
222            callees("a :- (b ; \\+ c).\n"),
223            vec![("b".into(), 0), ("c".into(), 0)]
224        );
225    }
226
227    #[test]
228    fn recurses_into_findall_goal_and_catch() {
229        assert_eq!(
230            callees("a :- findall(X, gen(X), _).\n"),
231            vec![("gen".into(), 1)]
232        );
233        assert_eq!(
234            callees("a :- catch(risky, _, recover).\n"),
235            vec![("recover".into(), 0), ("risky".into(), 0)]
236        );
237    }
238
239    #[test]
240    fn call_of_one_arg_is_checked_higher_arities_deferred() {
241        assert_eq!(callees("a :- call(b).\n"), vec![("b".into(), 0)]);
242        // call(G, X) -> effective arity unknowable; not flagged.
243        assert!(callees("a :- call(b, x).\n").is_empty());
244    }
245
246    #[test]
247    fn variable_goal_is_left_to_runtime() {
248        assert!(callees("a(G) :- G.\n").is_empty());
249    }
250
251    #[test]
252    fn suggests_a_near_defined_name() {
253        let mut interner = StringInterner::new();
254        let (clauses, directives) = Parser::parse_program_with_directives(
255            "parent(tom).\nq :- xarent(tom).\n",
256            &mut interner,
257        )
258        .unwrap();
259        let u = &undefined_calls(&clauses, &directives, &interner)[0];
260        assert_eq!(u.suggestion.as_deref(), Some("parent/1"));
261    }
262}