Skip to main content

plg_runtime/
query.rs

1//! Minimal goal-only parser for runtime `--query` strings.
2//!
3//! Deliberately NOT the full plg-frontend parser (binary size): a query
4//! is one goal term. Supports atoms (plain and quoted), variables,
5//! integers, compounds, lists, and the standard operator set via the
6//! precedence climber in `query::ops` — the levels mirror
7//! plg-frontend/src/parser (the reference implementation).
8//!
9//! Terms are built directly on the machine heap; query variables are
10//! recorded in `m.query_vars` (first-occurrence order, `_` excluded —
11//! the renderer sorts by name).
12
13mod ops;
14
15use crate::cell::{self, Word};
16use crate::machine::Machine;
17use std::collections::HashMap;
18
19pub fn parse_query(m: &mut Machine, src: &str) -> Result<Word, String> {
20    let mut p = QueryParser {
21        chars: src.chars().collect(),
22        pos: 0,
23        vars: HashMap::new(),
24    };
25    let goal = p.parse_level(m, 1200)?;
26    p.skip_ws();
27    // Tolerate a trailing '.'.
28    if p.peek() == Some('.') {
29        p.pos += 1;
30        p.skip_ws();
31    }
32    if p.pos < p.chars.len() {
33        return Err(format!("unexpected input at column {}", p.pos + 1));
34    }
35    Ok(goal)
36}
37
38pub(crate) struct QueryParser {
39    pub(crate) chars: Vec<char>,
40    pub(crate) pos: usize,
41    vars: HashMap<String, Word>,
42}
43
44impl QueryParser {
45    pub(crate) fn peek(&self) -> Option<char> {
46        self.chars.get(self.pos).copied()
47    }
48
49    pub(crate) fn skip_ws(&mut self) {
50        while let Some(c) = self.peek() {
51            if c.is_whitespace() {
52                self.pos += 1;
53            } else if c == '%' {
54                while self.peek().is_some_and(|c| c != '\n') {
55                    self.pos += 1;
56                }
57            } else {
58                break;
59            }
60        }
61    }
62
63    fn expect(&mut self, c: char) -> Result<(), String> {
64        self.skip_ws();
65        if self.peek() == Some(c) {
66            self.pos += 1;
67            Ok(())
68        } else {
69            Err(format!("expected `{c}` at column {}", self.pos + 1))
70        }
71    }
72
73    pub(crate) fn make_binop(&self, m: &mut Machine, name: &str, a: Word, b: Word) -> Word {
74        let id = m.atoms.intern(name);
75        let idx = m.heap.len();
76        m.heap.push(cell::pack_functor(id, 2));
77        m.heap.push(a);
78        m.heap.push(b);
79        cell::make(cell::TAG_STR, idx as u64)
80    }
81
82    /// A primary term: constants, variables, compounds, lists, parens.
83    pub(crate) fn parse_primary(&mut self, m: &mut Machine) -> Result<Word, String> {
84        self.skip_ws();
85        match self.peek() {
86            None => Err("unexpected end of query".to_string()),
87            Some('(') => {
88                self.pos += 1;
89                let t = self.parse_level(m, 1200)?;
90                self.expect(')')?;
91                Ok(t)
92            }
93            Some('[') => self.parse_list(m),
94            Some('\'') => {
95                let name = self.read_quoted()?;
96                self.parse_atom_or_compound(m, name)
97            }
98            Some(c) if c.is_ascii_digit() => self.parse_number(m, false),
99            // Standalone operator atoms (`(-)`, `[+, *]`, `call(=, X, Y)`)
100            // and canonical compound forms (`=(X, 7)`): a symbol run
101            // followed by `(` is a compound, by a term-end is an atom.
102            Some(c) if is_symbol_atom_char(c) && self.symbol_run_is_standalone() => {
103                let name = self.read_symbol_run();
104                self.parse_atom_or_compound(m, name)
105            }
106            Some('-')
107                if self
108                    .chars
109                    .get(self.pos + 1)
110                    .is_some_and(|c| c.is_ascii_digit()) =>
111            {
112                self.pos += 1;
113                self.parse_number(m, true)
114            }
115            Some('-') => {
116                // Prefix minus over a non-literal: -(Term).
117                self.pos += 1;
118                let t = self.parse_primary(m)?;
119                self.make_prefix(m, "-", t)
120            }
121            Some('+') => {
122                // Prefix plus: folds into a positive numeric literal
123                // otherwise builds '+'/1.
124                self.pos += 1;
125                self.skip_ws();
126                if self.peek().is_some_and(|c| c.is_ascii_digit()) {
127                    self.parse_number(m, false)
128                } else {
129                    let t = self.parse_primary(m)?;
130                    self.make_prefix(m, "+", t)
131                }
132            }
133            Some('\\') => {
134                // Prefix `\` (and `\+` reached here only as an atom —
135                // the level-900 parser handles real negation).
136                let name = self.read_symbol_run();
137                if self.peek() == Some('(') {
138                    self.parse_atom_or_compound(m, name)
139                } else {
140                    let t = self.parse_primary(m)?;
141                    self.make_prefix(m, &name, t)
142                }
143            }
144            Some('!') => {
145                self.pos += 1;
146                Ok(cell::make_atom(m.atoms.intern("!")))
147            }
148            Some(c) if c.is_uppercase() || c == '_' => {
149                let name = self.read_ident();
150                Ok(self.var_word(m, &name))
151            }
152            Some(c) if c.is_lowercase() => {
153                let name = self.read_ident();
154                self.parse_atom_or_compound(m, name)
155            }
156            Some(c) => Err(format!("unexpected `{c}` at column {}", self.pos + 1)),
157        }
158    }
159
160    fn parse_atom_or_compound(&mut self, m: &mut Machine, name: String) -> Result<Word, String> {
161        let id = m.atoms.intern(&name);
162        // No whitespace allowed between functor and `(` (ISO).
163        if self.peek() == Some('(') {
164            self.pos += 1;
165            // Arguments parse at priority 999 (no bare `,`).
166            let mut args = vec![self.parse_level(m, 999)?];
167            loop {
168                self.skip_ws();
169                match self.peek() {
170                    Some(',') => {
171                        self.pos += 1;
172                        args.push(self.parse_level(m, 999)?);
173                    }
174                    Some(')') => {
175                        self.pos += 1;
176                        break;
177                    }
178                    _ => return Err(format!("expected `,` or `)` at column {}", self.pos + 1)),
179                }
180            }
181            let idx = m.heap.len();
182            m.heap.push(cell::pack_functor(id, args.len() as u32));
183            m.heap.extend_from_slice(&args);
184            Ok(cell::make(cell::TAG_STR, idx as u64))
185        } else {
186            Ok(cell::make_atom(id))
187        }
188    }
189
190    fn parse_list(&mut self, m: &mut Machine) -> Result<Word, String> {
191        self.expect('[')?;
192        self.skip_ws();
193        if self.peek() == Some(']') {
194            self.pos += 1;
195            return Ok(cell::make_atom(plg_shared::atom::ATOM_NIL));
196        }
197        let mut elements = vec![self.parse_level(m, 999)?];
198        let mut tail = None;
199        loop {
200            self.skip_ws();
201            match self.peek() {
202                Some(',') => {
203                    self.pos += 1;
204                    elements.push(self.parse_level(m, 999)?);
205                }
206                Some('|') => {
207                    self.pos += 1;
208                    tail = Some(self.parse_level(m, 999)?);
209                    self.expect(']')?;
210                    break;
211                }
212                Some(']') => {
213                    self.pos += 1;
214                    break;
215                }
216                _ => {
217                    return Err(format!(
218                        "expected `,`, `|` or `]` at column {}",
219                        self.pos + 1
220                    ));
221                }
222            }
223        }
224        let mut w = tail.unwrap_or(cell::make_atom(plg_shared::atom::ATOM_NIL));
225        for e in elements.into_iter().rev() {
226            let idx = m.heap.len();
227            m.heap.push(e);
228            m.heap.push(w);
229            w = cell::make(cell::TAG_LST, idx as u64);
230        }
231        Ok(w)
232    }
233
234    /// Integer or float literal. A `.` continues a float only when a
235    /// digit follows (otherwise it's the goal terminator) — same
236    /// disambiguation as the frontend tokenizer.
237    fn parse_number(&mut self, m: &mut Machine, neg: bool) -> Result<Word, String> {
238        let start = self.pos;
239        while self.peek().is_some_and(|c| c.is_ascii_digit()) {
240            self.pos += 1;
241        }
242        let is_float = self.peek() == Some('.')
243            && self
244                .chars
245                .get(self.pos + 1)
246                .is_some_and(|c| c.is_ascii_digit());
247        if is_float {
248            self.pos += 1; // '.'
249            while self.peek().is_some_and(|c| c.is_ascii_digit()) {
250                self.pos += 1;
251            }
252            let text: String = self.chars[start..self.pos].iter().collect();
253            let f: f64 = text
254                .parse()
255                .map_err(|_| format!("invalid float `{text}`"))?;
256            let f = if neg { -f } else { f };
257            let idx = m.heap.len();
258            m.heap.push(f.to_bits());
259            return Ok(cell::make(cell::TAG_FLT, idx as u64));
260        }
261        let digits: String = self.chars[start..self.pos].iter().collect();
262        let n: i64 = digits
263            .parse()
264            .map_err(|_| format!("invalid integer `{digits}`"))?;
265        let n = if neg { -n } else { n };
266        if !(cell::INT_MIN..=cell::INT_MAX).contains(&n) {
267            // Box beyond-immediate integers (full i64 range).
268            let idx = m.heap.len();
269            m.heap.push(n as u64);
270            return Ok(cell::make(cell::TAG_BIG, idx as u64));
271        }
272        Ok(cell::make_int(n))
273    }
274
275    pub(crate) fn read_ident(&mut self) -> String {
276        let start = self.pos;
277        while self.peek().is_some_and(|c| c.is_alphanumeric() || c == '_') {
278            self.pos += 1;
279        }
280        self.chars[start..self.pos].iter().collect()
281    }
282
283    fn read_quoted(&mut self) -> Result<String, String> {
284        self.pos += 1; // opening '
285        let mut out = String::new();
286        loop {
287            match self.peek() {
288                None => return Err("unterminated quoted atom".to_string()),
289                Some('\'') => {
290                    self.pos += 1;
291                    if self.peek() == Some('\'') {
292                        out.push('\''); // '' escape
293                        self.pos += 1;
294                    } else {
295                        return Ok(out);
296                    }
297                }
298                Some(c) => {
299                    out.push(c);
300                    self.pos += 1;
301                }
302            }
303        }
304    }
305
306    fn read_symbol_run(&mut self) -> String {
307        let start = self.pos;
308        while self.peek().is_some_and(is_symbol_atom_char) {
309            self.pos += 1;
310        }
311        self.chars[start..self.pos].iter().collect()
312    }
313
314    /// Does the symbol run starting at the cursor stand alone as an
315    /// atom (followed by a term-end) or start a canonical compound
316    /// (immediately followed by `(`)?
317    fn symbol_run_is_standalone(&self) -> bool {
318        let mut p = self.pos;
319        while self.chars.get(p).copied().is_some_and(is_symbol_atom_char) {
320            p += 1;
321        }
322        if self.chars.get(p) == Some(&'(') {
323            return true; // canonical compound: =(X, 7)
324        }
325        let mut q = p;
326        while self.chars.get(q).is_some_and(|c| c.is_whitespace()) {
327            q += 1;
328        }
329        match self.chars.get(q) {
330            None => {
331                // End of input — but a trailing '.' inside the run is
332                // the goal terminator, not part of the atom; only the
333                // run itself ending the input counts.
334                true
335            }
336            Some(')' | ',' | ']' | '|') => true,
337            _ => false,
338        }
339    }
340
341    fn make_prefix(&self, m: &mut Machine, name: &str, t: Word) -> Result<Word, String> {
342        let id = m.atoms.intern(name);
343        let idx = m.heap.len();
344        m.heap.push(cell::pack_functor(id, 1));
345        m.heap.push(t);
346        Ok(cell::make(cell::TAG_STR, idx as u64))
347    }
348
349    /// `_` is always fresh and never recorded; named variables are
350    /// shared within the query and recorded for solution output.
351    fn var_word(&mut self, m: &mut Machine, name: &str) -> Word {
352        if name == "_" {
353            return m.new_var();
354        }
355        if let Some(&w) = self.vars.get(name) {
356            return w;
357        }
358        let w = m.new_var();
359        self.vars.insert(name.to_string(), w);
360        m.query_vars
361            .push((name.to_string(), cell::payload(w) as usize));
362        w
363    }
364}
365
366/// Characters that form symbolic atoms (Prolog "symbol char" class).
367fn is_symbol_atom_char(c: char) -> bool {
368    matches!(
369        c,
370        '+' | '-' | '*' | '/' | '\\' | '<' | '>' | '=' | ':' | '@' | '^' | '.' | '?' | '&' | '~'
371    )
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::cell::*;
378    use plg_shared::StringInterner;
379
380    fn machine() -> Box<Machine> {
381        Machine::new(StringInterner::new(), Vec::new())
382    }
383
384    fn functor_name(m: &Machine, w: Word) -> String {
385        let idx = payload(w) as usize;
386        let (f, _) = unpack_functor(m.heap[idx]);
387        m.atoms.resolve(f).to_string()
388    }
389
390    #[test]
391    fn parses_compound_with_vars() {
392        let mut m = machine();
393        let w = parse_query(&mut m, "parent(tom, X)").unwrap();
394        assert_eq!(tag_of(w), TAG_STR);
395        assert_eq!(functor_name(&m, w), "parent");
396        assert_eq!(m.query_vars.len(), 1);
397        assert_eq!(m.query_vars[0].0, "X");
398    }
399
400    #[test]
401    fn conjunction_shares_variables() {
402        let mut m = machine();
403        let w = parse_query(&mut m, "p(X), q(X, Y)").unwrap();
404        assert_eq!(functor_name(&m, w), ",");
405        assert_eq!(m.query_vars.len(), 2, "X shared, Y new");
406    }
407
408    #[test]
409    fn operator_goals_parse() {
410        let mut m = machine();
411        let w = parse_query(&mut m, "X is 2 + 3 * 4").unwrap();
412        assert_eq!(functor_name(&m, w), "is");
413        let w = parse_query(&mut m, "1 < 2").unwrap();
414        assert_eq!(functor_name(&m, w), "<");
415        let w = parse_query(&mut m, "(a ; b)").unwrap();
416        assert_eq!(functor_name(&m, w), ";");
417        let w = parse_query(&mut m, "(a -> b ; c)").unwrap();
418        assert_eq!(functor_name(&m, w), ";");
419        let w = parse_query(&mut m, "\\+ p(X)").unwrap();
420        assert_eq!(functor_name(&m, w), "\\+");
421    }
422
423    #[test]
424    fn precedence_multiplication_binds_tighter() {
425        let mut m = machine();
426        // is(X, +(2, *(3, 4)))
427        let w = parse_query(&mut m, "X is 2 + 3 * 4").unwrap();
428        let idx = payload(w) as usize;
429        let rhs = m.deref(m.heap[idx + 2]);
430        assert_eq!(functor_name(&m, rhs), "+");
431        let plus_idx = payload(rhs) as usize;
432        let right = m.deref(m.heap[plus_idx + 2]);
433        assert_eq!(functor_name(&m, right), "*");
434    }
435
436    #[test]
437    fn args_parse_operators_but_not_bare_comma() {
438        let mut m = machine();
439        let w = parse_query(&mut m, "p(1 + 2, X)").unwrap();
440        let idx = payload(w) as usize;
441        let (f, n) = unpack_functor(m.heap[idx]);
442        assert_eq!(m.atoms.resolve(f), "p");
443        assert_eq!(n, 2, "1 + 2 is one arg, X the other");
444    }
445
446    #[test]
447    fn lists_and_quoted_atoms() {
448        let mut m = machine();
449        let w = parse_query(&mut m, "p([1, 2 | T], 'hello world')").unwrap();
450        assert_eq!(tag_of(w), TAG_STR);
451        assert!(m.atoms.lookup("hello world").is_some());
452        assert_eq!(m.query_vars[0].0, "T");
453    }
454
455    #[test]
456    fn underscore_never_recorded() {
457        let mut m = machine();
458        parse_query(&mut m, "p(_, _)").unwrap();
459        assert!(m.query_vars.is_empty());
460    }
461
462    #[test]
463    fn negative_integers_and_trailing_dot() {
464        let mut m = machine();
465        let w = parse_query(&mut m, "p(-42).").unwrap();
466        let idx = payload(w) as usize;
467        assert_eq!(int_value(m.deref(m.heap[idx + 1])), -42);
468    }
469
470    #[test]
471    fn rejects_trailing_garbage() {
472        let mut m = machine();
473        assert!(parse_query(&mut m, "p(a) q").is_err());
474        assert!(parse_query(&mut m, "p(").is_err());
475    }
476}