Skip to main content

patch_prolog_shared/
builtins.rs

1//! The builtin / control-construct vocabulary: the single source of
2//! truth for *which* names the language knows, their arities, and a
3//! one-line doc each. Reconciled in `docs/design/BUILTIN_VOCAB.md`.
4//!
5//! This is **data only** — no symbol mapping (that stays compiler-side
6//! in `codegen/lower.rs::DET_BUILTINS`) and no dispatch (the runtime
7//! keeps its own `match`). Codegen and the runtime are *checked against*
8//! this table; the LSP (completion + hover) *reads* it directly.
9//!
10//! Zero-dependency, like the rest of `plg-shared`. IMPORTANT: the
11//! runtime must NOT reference `BUILTINS` outside `#[cfg(test)]` — the
12//! `doc` strings would otherwise land in every compiled program binary
13//! (see the doc's "doc strings must never reach a compiled program"
14//! constraint).
15
16/// Where a name is handled, used only to partition the validation that
17/// codegen/runtime stay in sync with this table. Not a dispatch hint.
18#[derive(Clone, Copy, PartialEq, Eq, Debug)]
19pub enum BuiltinKind {
20    /// Control construct — handled structurally in codegen/runtime
21    /// (`,` `;` `->` `\+` `once` `catch` `throw` `findall` `call`
22    /// `between`).
23    Control,
24    /// Inline goal — its own `LGoal` variant or an op-code table
25    /// (`=` `\=` `is` `compare`, arithmetic and term-order comparisons).
26    Inline,
27    /// Deterministic builtin dispatched to a `plg_rt_b_*` symbol
28    /// (the `DET_BUILTINS` set).
29    Det,
30    /// Reserved arity-0 atom goal (`true` `fail` `false` `!`).
31    Atom,
32}
33
34/// One vocabulary entry. `arity` is the canonical arity; `call/N` is
35/// listed once at its minimum arity (1) and noted variadic in `doc`.
36#[derive(Clone, Copy, Debug)]
37pub struct BuiltinSpec {
38    pub name: &'static str,
39    pub arity: u32,
40    pub kind: BuiltinKind,
41    pub doc: &'static str,
42}
43
44impl BuiltinSpec {
45    /// Completion-eligible iff the name is a typeable identifier (first
46    /// char ASCII-alphabetic). Offers `findall`/`once`/`is`/`compare`/
47    /// `nl`, suppresses operators and `!`. Deliberately NOT derived from
48    /// `kind`: `is`/`compare` (Inline) complete, `\+` (Control) does not.
49    /// If a real counter-example appears, promote this to an explicit
50    /// `complete: bool` column on `BuiltinSpec` and overrule per row.
51    pub fn completable(&self) -> bool {
52        self.name
53            .chars()
54            .next()
55            .is_some_and(|c| c.is_ascii_alphabetic())
56    }
57}
58
59use BuiltinKind::{Atom, Control, Det, Inline};
60
61/// The full vocabulary (55 rows). Docs for everything except `,` `;`
62/// `->` port verbatim from v1's `BUILTIN_DOCS`; those three are new.
63/// `rustfmt::skip` keeps it as a one-row-per-line table — the doc
64/// strings would otherwise wrap to five lines each.
65#[rustfmt::skip]
66pub const BUILTINS: &[BuiltinSpec] = &[
67    // --- Det: deterministic builtins (DET_BUILTINS mirror) ---
68    b(Det, "var", 1, "Type check: succeeds if argument is an unbound variable."),
69    b(Det, "nonvar", 1, "Type check: succeeds if argument is bound."),
70    b(Det, "atom", 1, "Type check: succeeds if argument is an atom."),
71    b(Det, "number", 1, "Type check: succeeds if argument is an integer or float."),
72    b(Det, "integer", 1, "Type check: succeeds if argument is an integer."),
73    b(Det, "float", 1, "Type check: succeeds if argument is a float."),
74    b(Det, "compound", 1, "Type check: succeeds if argument is a compound term."),
75    b(Det, "is_list", 1, "Type check: succeeds if argument is a proper list."),
76    b(Det, "functor", 3, "`functor(Term, Name, Arity)` — inspect or construct a term's functor."),
77    b(Det, "arg", 3, "`arg(N, Term, Arg)` — extract the N-th argument of Term."),
78    b(Det, "=..", 2, "Univ: `T =.. L` decomposes T into a list of its functor and args."),
79    b(Det, "copy_term", 2, "`copy_term(T, C)` — bind C to a copy of T with fresh variables."),
80    b(Det, "atom_length", 2, "`atom_length(A, L)` — bind L to the length of atom A."),
81    b(Det, "atom_concat", 3, "`atom_concat(A, B, C)` — concatenate atoms A and B into C."),
82    b(Det, "atom_chars", 2, "`atom_chars(A, Chars)` — convert between an atom and a list of single-char atoms."),
83    b(Det, "number_chars", 2, "`number_chars(N, Chars)` — convert between a number and a list of single-char atoms."),
84    b(Det, "number_codes", 2, "`number_codes(N, Codes)` — convert between a number and a list of character codes."),
85    b(Det, "msort", 2, "`msort(L, Sorted)` — sort without removing duplicates."),
86    b(Det, "sort", 2, "`sort(L, Sorted)` — sort and remove duplicates."),
87    b(Det, "succ", 2, "`succ(X, S)` — Peano successor relation; S = X + 1, both non-negative."),
88    b(Det, "plus", 3, "`plus(X, Y, Z)` — addition relation; any one argument may be unbound."),
89    b(Det, "unify_with_occurs_check", 2, "Unification with occurs check: rejects `X = f(X)`-style cycles."),
90    b(Det, "write", 1, "Write a term to stdout (no newline)."),
91    b(Det, "writeln", 1, "Write a term to stdout followed by a newline."),
92    b(Det, "nl", 0, "Write a newline to stdout."),
93    // --- Inline: own LGoal variant / op-code table ---
94    b(Inline, "=", 2, "Unification: `X = Y` succeeds if X and Y can be made identical."),
95    b(Inline, "\\=", 2, "Not-unifiable: succeeds when `=` would fail."),
96    b(Inline, "is", 2, "Arithmetic evaluation: `X is Expr` binds X to the value of Expr."),
97    b(Inline, "compare", 3, "`compare(Order, T1, T2)` — bind Order to <, =, or > per standard term ordering."),
98    b(Inline, "==", 2, "Term identity: structural equality without unification."),
99    b(Inline, "\\==", 2, "Term non-identity."),
100    b(Inline, "@<", 2, "Standard term ordering: less."),
101    b(Inline, "@>", 2, "Standard term ordering: greater."),
102    b(Inline, "@=<", 2, "Standard term ordering: less-or-equal."),
103    b(Inline, "@>=", 2, "Standard term ordering: greater-or-equal."),
104    b(Inline, "<", 2, "Arithmetic less-than."),
105    b(Inline, ">", 2, "Arithmetic greater-than."),
106    b(Inline, "=<", 2, "Arithmetic less-or-equal (note: `=<`, not `<=`)."),
107    b(Inline, ">=", 2, "Arithmetic greater-or-equal."),
108    b(Inline, "=:=", 2, "Arithmetic equality."),
109    b(Inline, "=\\=", 2, "Arithmetic inequality."),
110    // --- Control: structural constructs ---
111    b(Control, ",", 2, "`(A, B)` — conjunction: prove A, then B."),
112    b(Control, ";", 2, "`(A ; B)` — disjunction: prove A, or B on backtracking. `(C -> T ; E)` reads as if-then-else."),
113    b(Control, "->", 2, "`(C -> T)` — if-then: if C succeeds (committing to its first solution), prove T; otherwise fail."),
114    b(Control, "\\+", 1, "Negation as failure: succeeds when its argument fails."),
115    b(Control, "once", 1, "`once(Goal)` — succeed at most once for Goal."),
116    b(Control, "catch", 3, "`catch(Goal, Catcher, Recovery)` — run Goal; on thrown error matching Catcher, run Recovery."),
117    b(Control, "throw", 1, "Raise an error term that propagates to the nearest matching `catch/3`."),
118    b(Control, "findall", 3, "`findall(Template, Goal, List)` — collect all solutions of Goal."),
119    b(Control, "call", 1, "Meta-call: execute its argument as a goal. Variadic — extra args are appended."),
120    b(Control, "between", 3, "`between(Low, High, X)` — enumerate or test integers in [Low, High]."),
121    // --- Atom: reserved arity-0 goals ---
122    b(Atom, "true", 0, "Always succeeds."),
123    b(Atom, "fail", 0, "Always fails."),
124    b(Atom, "false", 0, "Always fails (alias for `fail`)."),
125    b(Atom, "!", 0, "Cut: commit to current choices; remove choice points back to the parent clause."),
126];
127
128/// Const ctor so the table above reads as one row per line.
129const fn b(kind: BuiltinKind, name: &'static str, arity: u32, doc: &'static str) -> BuiltinSpec {
130    BuiltinSpec {
131        name,
132        arity,
133        kind,
134        doc,
135    }
136}
137
138/// Exact lookup by name and arity (`call/N` matches only at arity 1).
139pub fn lookup(name: &str, arity: u32) -> Option<&'static BuiltinSpec> {
140    BUILTINS.iter().find(|s| s.name == name && s.arity == arity)
141}
142
143/// First doc for `name` regardless of arity — hover is arity-insensitive
144/// (the cursor is on a name, not a resolved call).
145pub fn doc(name: &str) -> Option<&'static str> {
146    BUILTINS.iter().find(|s| s.name == name).map(|s| s.doc)
147}
148
149/// Completion: arity-0 names worth offering.
150pub fn atom_names() -> impl Iterator<Item = &'static str> {
151    BUILTINS
152        .iter()
153        .filter(|s| s.arity == 0 && s.completable())
154        .map(|s| s.name)
155}
156
157/// Completion: (name, arity) for arity->0 names worth offering.
158pub fn functor_names() -> impl Iterator<Item = (&'static str, u32)> {
159    BUILTINS
160        .iter()
161        .filter(|s| s.arity > 0 && s.completable())
162        .map(|s| (s.name, s.arity))
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn roster_size_and_partition() {
171        assert_eq!(BUILTINS.len(), 55, "roster size changed — update the doc");
172        let count = |k: BuiltinKind| BUILTINS.iter().filter(|s| s.kind == k).count();
173        assert_eq!(count(Det), 25);
174        assert_eq!(count(Inline), 16);
175        assert_eq!(count(Control), 10);
176        assert_eq!(count(Atom), 4);
177    }
178
179    #[test]
180    fn no_duplicate_name_arity() {
181        for (i, s) in BUILTINS.iter().enumerate() {
182            for t in &BUILTINS[i + 1..] {
183                assert!(
184                    !(s.name == t.name && s.arity == t.arity),
185                    "duplicate {}/{}",
186                    s.name,
187                    s.arity
188                );
189            }
190        }
191    }
192
193    #[test]
194    fn every_row_has_a_doc() {
195        for s in BUILTINS {
196            assert!(!s.doc.is_empty(), "{}/{} has no doc", s.name, s.arity);
197        }
198    }
199
200    /// The user-facing reference must enumerate every builtin — drift guard
201    /// so `docs/builtin-reference.md` can't silently fall behind the table.
202    #[test]
203    fn reference_doc_covers_every_builtin() {
204        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
205            .join("../../docs/builtin-reference.md");
206        let doc = std::fs::read_to_string(&path).expect("docs/builtin-reference.md must exist");
207        for s in BUILTINS {
208            let entry = format!("{}/{}", s.name, s.arity);
209            assert!(
210                doc.contains(&entry),
211                "{entry} is missing from {}",
212                path.display()
213            );
214        }
215    }
216
217    #[test]
218    fn completable_tracks_identifier_not_kind() {
219        // alphabetic-leading names complete, regardless of kind...
220        assert!(lookup("is", 2).unwrap().completable()); // Inline, yes
221        assert!(lookup("compare", 3).unwrap().completable()); // Inline, yes
222        assert!(lookup("once", 1).unwrap().completable()); // Control, yes
223        assert!(lookup("catch", 3).unwrap().completable()); // Control, yes (v1 omitted)
224        assert!(lookup("nl", 0).unwrap().completable()); // Det atom-arity, yes
225        // ...operators and `!` do not.
226        assert!(!lookup("\\+", 1).unwrap().completable()); // Control, no
227        assert!(!lookup("=..", 2).unwrap().completable()); // Det operator, no
228        assert!(!lookup("!", 0).unwrap().completable()); // Atom, no
229        assert!(!lookup(";", 2).unwrap().completable()); // Control, no
230    }
231
232    #[test]
233    fn accessors_respect_completable() {
234        let atoms: Vec<_> = atom_names().collect();
235        assert!(atoms.contains(&"true") && atoms.contains(&"nl"));
236        assert!(!atoms.contains(&"!"));
237        let fns: Vec<_> = functor_names().collect();
238        assert!(fns.contains(&("once", 1)) && fns.contains(&("catch", 3)));
239        assert!(!fns.contains(&(",", 2)) && !fns.contains(&("=..", 2)));
240    }
241}