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 (56 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, "writeq", 1, "Write a term to stdout, quoting atoms so it reads back (no newline)."),
92    b(Det, "writeln", 1, "Write a term to stdout followed by a newline."),
93    b(Det, "nl", 0, "Write a newline to stdout."),
94    // --- Inline: own LGoal variant / op-code table ---
95    b(Inline, "=", 2, "Unification: `X = Y` succeeds if X and Y can be made identical."),
96    b(Inline, "\\=", 2, "Not-unifiable: succeeds when `=` would fail."),
97    b(Inline, "is", 2, "Arithmetic evaluation: `X is Expr` binds X to the value of Expr."),
98    b(Inline, "compare", 3, "`compare(Order, T1, T2)` — bind Order to <, =, or > per standard term ordering."),
99    b(Inline, "==", 2, "Term identity: structural equality without unification."),
100    b(Inline, "\\==", 2, "Term non-identity."),
101    b(Inline, "@<", 2, "Standard term ordering: less."),
102    b(Inline, "@>", 2, "Standard term ordering: greater."),
103    b(Inline, "@=<", 2, "Standard term ordering: less-or-equal."),
104    b(Inline, "@>=", 2, "Standard term ordering: greater-or-equal."),
105    b(Inline, "<", 2, "Arithmetic less-than."),
106    b(Inline, ">", 2, "Arithmetic greater-than."),
107    b(Inline, "=<", 2, "Arithmetic less-or-equal (note: `=<`, not `<=`)."),
108    b(Inline, ">=", 2, "Arithmetic greater-or-equal."),
109    b(Inline, "=:=", 2, "Arithmetic equality."),
110    b(Inline, "=\\=", 2, "Arithmetic inequality."),
111    // --- Control: structural constructs ---
112    b(Control, ",", 2, "`(A, B)` — conjunction: prove A, then B."),
113    b(Control, ";", 2, "`(A ; B)` — disjunction: prove A, or B on backtracking. `(C -> T ; E)` reads as if-then-else."),
114    b(Control, "->", 2, "`(C -> T)` — if-then: if C succeeds (committing to its first solution), prove T; otherwise fail."),
115    b(Control, "\\+", 1, "Negation as failure: succeeds when its argument fails."),
116    b(Control, "once", 1, "`once(Goal)` — succeed at most once for Goal."),
117    b(Control, "catch", 3, "`catch(Goal, Catcher, Recovery)` — run Goal; on thrown error matching Catcher, run Recovery."),
118    b(Control, "throw", 1, "Raise an error term that propagates to the nearest matching `catch/3`."),
119    b(Control, "findall", 3, "`findall(Template, Goal, List)` — collect all solutions of Goal."),
120    b(Control, "call", 1, "Meta-call: execute its argument as a goal. Variadic — extra args are appended."),
121    b(Control, "between", 3, "`between(Low, High, X)` — enumerate or test integers in [Low, High]."),
122    // --- Atom: reserved arity-0 goals ---
123    b(Atom, "true", 0, "Always succeeds."),
124    b(Atom, "fail", 0, "Always fails."),
125    b(Atom, "false", 0, "Always fails (alias for `fail`)."),
126    b(Atom, "!", 0, "Cut: commit to current choices; remove choice points back to the parent clause."),
127];
128
129/// Const ctor so the table above reads as one row per line.
130const fn b(kind: BuiltinKind, name: &'static str, arity: u32, doc: &'static str) -> BuiltinSpec {
131    BuiltinSpec {
132        name,
133        arity,
134        kind,
135        doc,
136    }
137}
138
139/// Exact lookup by name and arity (`call/N` matches only at arity 1).
140pub fn lookup(name: &str, arity: u32) -> Option<&'static BuiltinSpec> {
141    BUILTINS.iter().find(|s| s.name == name && s.arity == arity)
142}
143
144/// First doc for `name` regardless of arity — hover is arity-insensitive
145/// (the cursor is on a name, not a resolved call).
146pub fn doc(name: &str) -> Option<&'static str> {
147    BUILTINS.iter().find(|s| s.name == name).map(|s| s.doc)
148}
149
150/// Completion: arity-0 names worth offering.
151pub fn atom_names() -> impl Iterator<Item = &'static str> {
152    BUILTINS
153        .iter()
154        .filter(|s| s.arity == 0 && s.completable())
155        .map(|s| s.name)
156}
157
158/// Completion: (name, arity) for arity->0 names worth offering.
159pub fn functor_names() -> impl Iterator<Item = (&'static str, u32)> {
160    BUILTINS
161        .iter()
162        .filter(|s| s.arity > 0 && s.completable())
163        .map(|s| (s.name, s.arity))
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn roster_size_and_partition() {
172        assert_eq!(BUILTINS.len(), 56, "roster size changed — update the doc");
173        let count = |k: BuiltinKind| BUILTINS.iter().filter(|s| s.kind == k).count();
174        assert_eq!(count(Det), 26);
175        assert_eq!(count(Inline), 16);
176        assert_eq!(count(Control), 10);
177        assert_eq!(count(Atom), 4);
178    }
179
180    #[test]
181    fn no_duplicate_name_arity() {
182        for (i, s) in BUILTINS.iter().enumerate() {
183            for t in &BUILTINS[i + 1..] {
184                assert!(
185                    !(s.name == t.name && s.arity == t.arity),
186                    "duplicate {}/{}",
187                    s.name,
188                    s.arity
189                );
190            }
191        }
192    }
193
194    #[test]
195    fn every_row_has_a_doc() {
196        for s in BUILTINS {
197            assert!(!s.doc.is_empty(), "{}/{} has no doc", s.name, s.arity);
198        }
199    }
200
201    /// The user-facing reference must enumerate every builtin — drift guard
202    /// so `docs/builtin-reference.md` can't silently fall behind the table.
203    #[test]
204    fn reference_doc_covers_every_builtin() {
205        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
206            .join("../../docs/builtin-reference.md");
207        let doc = std::fs::read_to_string(&path).expect("docs/builtin-reference.md must exist");
208        for s in BUILTINS {
209            let entry = format!("{}/{}", s.name, s.arity);
210            assert!(
211                doc.contains(&entry),
212                "{entry} is missing from {}",
213                path.display()
214            );
215        }
216    }
217
218    #[test]
219    fn completable_tracks_identifier_not_kind() {
220        // alphabetic-leading names complete, regardless of kind...
221        assert!(lookup("is", 2).unwrap().completable()); // Inline, yes
222        assert!(lookup("compare", 3).unwrap().completable()); // Inline, yes
223        assert!(lookup("once", 1).unwrap().completable()); // Control, yes
224        assert!(lookup("catch", 3).unwrap().completable()); // Control, yes (v1 omitted)
225        assert!(lookup("nl", 0).unwrap().completable()); // Det atom-arity, yes
226        // ...operators and `!` do not.
227        assert!(!lookup("\\+", 1).unwrap().completable()); // Control, no
228        assert!(!lookup("=..", 2).unwrap().completable()); // Det operator, no
229        assert!(!lookup("!", 0).unwrap().completable()); // Atom, no
230        assert!(!lookup(";", 2).unwrap().completable()); // Control, no
231    }
232
233    #[test]
234    fn accessors_respect_completable() {
235        let atoms: Vec<_> = atom_names().collect();
236        assert!(atoms.contains(&"true") && atoms.contains(&"nl"));
237        assert!(!atoms.contains(&"!"));
238        let fns: Vec<_> = functor_names().collect();
239        assert!(fns.contains(&("once", 1)) && fns.contains(&("catch", 3)));
240        assert!(!fns.contains(&(",", 2)) && !fns.contains(&("=..", 2)));
241    }
242}