1use crate::ProgramDirectives;
20use plg_shared::{AtomId, BUILTINS, Clause, StringInterner, Term};
21use std::collections::BTreeSet;
22
23pub struct Undefined {
25 pub callee: (String, usize),
26 pub caller: (String, usize),
27 pub suggestion: Option<String>,
29}
30
31pub 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 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
82pub 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
94fn 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; };
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", 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 _ if is_builtin(name, arity) => {}
128 _ 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
140fn 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
165fn 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 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 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}