Skip to main content

logicaffeine_proof/
formula.rs

1//! A self-contained parser from formal first-order-logic surface text to [`ProofExpr`].
2//!
3//! The natural-language front end (`logicaffeine_language`) is excellent at English —
4//! unary predicates, simple binary relations from transitive verbs — but a geometry
5//! axiom base needs *formal* relations of higher arity (`Cong(a,b,c,d)`, `Bet(a,b,c)`)
6//! and explicit quantifier prefixes that no English sentence expresses. This module is
7//! the seam: a small recursive-descent parser, deliberately independent of the 12k-line
8//! NL parser (the same self-containment that `tactic_script` has), turning a line like
9//!
10//! ```text
11//! for all a b c d e f, if Cong(a, b, c, d) and Cong(a, b, e, f) then Cong(c, d, e, f)
12//! ```
13//!
14//! directly into the [`ProofExpr`] the prover and the multi-theorem driver already
15//! consume. It is the surface for `## Axiom` / `## Theory` (the seam for Tarski).
16//!
17//! # Conventions
18//!
19//! * An identifier that leads with an uppercase letter or a digit is a CONSTANT
20//!   (`ProofTerm::Constant`) — a fixed point like `P`, `Q`, `A`; one that leads with a
21//!   lowercase letter is a VARIABLE (`ProofTerm::Variable`) — a bound point like `a`,
22//!   `b`. This matches the verifier's standard FOL reading (a lowercase-leading symbol
23//!   is a variable).
24//! * Connectives, lowest precedence first: quantifiers (`for all`, `there exists`) <
25//!   `iff` < implication (`if … then …`, `implies`, `->`) < `or` < `and` < `not` <
26//!   atoms. Implication is right-associative.
27//! * Both an English-flavoured spelling and a symbolic one are accepted: `and`/`∧`,
28//!   `or`/`∨`, `not`/`¬`, `implies`/`->`/`→`, `iff`/`<->`/`↔`, `for all`/`forall`/`∀`,
29//!   `there exists`/`exists`/`∃`.
30
31use crate::{ProofExpr, ProofTerm};
32
33/// A failure to parse formal-logic surface text.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct FormulaError {
36    pub message: String,
37}
38
39impl FormulaError {
40    pub(crate) fn new(message: impl Into<String>) -> Self {
41        FormulaError { message: message.into() }
42    }
43}
44
45impl std::fmt::Display for FormulaError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "formula parse error: {}", self.message)
48    }
49}
50
51impl std::error::Error for FormulaError {}
52
53/// Parse formal first-order-logic surface text into a [`ProofExpr`].
54///
55/// See the module docs for the grammar and the constant/variable convention. Returns a
56/// [`FormulaError`] on malformed input (the whole string must be consumed).
57pub fn parse_formula(input: &str) -> Result<ProofExpr, FormulaError> {
58    let tokens = tokenize(input)?;
59    let mut p = Parser { tokens: &tokens, pos: 0 };
60    let expr = p.parse_formula()?;
61    if p.pos != p.tokens.len() {
62        return Err(FormulaError::new(format!(
63            "unexpected trailing input near token {:?}",
64            p.tokens.get(p.pos)
65        )));
66    }
67    Ok(expr)
68}
69
70// ---------------------------------------------------------------------------
71// Tokenizer
72// ---------------------------------------------------------------------------
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75enum Tok {
76    Ident(String),
77    LParen,
78    RParen,
79    Comma,
80    Eq,
81    Arrow,    // -> →   (implication)
82    Iff,      // <-> ↔
83    And,      // ∧
84    Or,       // ∨
85    Not,      // ¬
86    Forall,   // ∀
87    Exists,   // ∃
88}
89
90fn tokenize(input: &str) -> Result<Vec<Tok>, FormulaError> {
91    let chars: Vec<char> = input.chars().collect();
92    let mut toks = Vec::new();
93    let mut i = 0;
94    while i < chars.len() {
95        let c = chars[i];
96        if c.is_whitespace() {
97            i += 1;
98            continue;
99        }
100        match c {
101            '(' => {
102                toks.push(Tok::LParen);
103                i += 1;
104            }
105            ')' => {
106                toks.push(Tok::RParen);
107                i += 1;
108            }
109            ',' => {
110                toks.push(Tok::Comma);
111                i += 1;
112            }
113            // A bare '.' is a benign body/clause terminator (e.g. ending a sentence);
114            // treated as whitespace so `forall a b. P` and `forall a b, P` both parse.
115            '.' | ';' => {
116                i += 1;
117            }
118            '=' => {
119                toks.push(Tok::Eq);
120                i += 1;
121            }
122            '∧' => {
123                toks.push(Tok::And);
124                i += 1;
125            }
126            '∨' => {
127                toks.push(Tok::Or);
128                i += 1;
129            }
130            '¬' => {
131                toks.push(Tok::Not);
132                i += 1;
133            }
134            '∀' => {
135                toks.push(Tok::Forall);
136                i += 1;
137            }
138            '∃' => {
139                toks.push(Tok::Exists);
140                i += 1;
141            }
142            '→' => {
143                toks.push(Tok::Arrow);
144                i += 1;
145            }
146            '↔' => {
147                toks.push(Tok::Iff);
148                i += 1;
149            }
150            '-' if i + 1 < chars.len() && chars[i + 1] == '>' => {
151                toks.push(Tok::Arrow);
152                i += 2;
153            }
154            '<' if i + 2 < chars.len() && chars[i + 1] == '-' && chars[i + 2] == '>' => {
155                toks.push(Tok::Iff);
156                i += 3;
157            }
158            c if c.is_alphanumeric() || c == '_' => {
159                let start = i;
160                while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
161                    i += 1;
162                }
163                toks.push(Tok::Ident(chars[start..i].iter().collect()));
164            }
165            other => {
166                return Err(FormulaError::new(format!("unexpected character '{other}'")));
167            }
168        }
169    }
170    Ok(toks)
171}
172
173// ---------------------------------------------------------------------------
174// Parser
175// ---------------------------------------------------------------------------
176
177struct Parser<'a> {
178    tokens: &'a [Tok],
179    pos: usize,
180}
181
182impl<'a> Parser<'a> {
183    fn peek(&self) -> Option<&Tok> {
184        self.tokens.get(self.pos)
185    }
186
187    fn advance(&mut self) -> Option<&Tok> {
188        let t = self.tokens.get(self.pos);
189        if t.is_some() {
190            self.pos += 1;
191        }
192        t
193    }
194
195    /// A lowercased keyword `Ident` at the cursor, if any (for the English spellings).
196    fn peek_kw(&self) -> Option<String> {
197        match self.peek() {
198            Some(Tok::Ident(s)) => Some(s.to_lowercase()),
199            _ => None,
200        }
201    }
202
203    fn eat_kw(&mut self, kw: &str) -> bool {
204        if self.peek_kw().as_deref() == Some(kw) {
205            self.pos += 1;
206            true
207        } else {
208            false
209        }
210    }
211
212    /// Whether a quantifier prefix begins at the cursor — `for all`/`forall`/`∀`,
213    /// `there exists`/`exists`/`some`/`∃`. A quantifier may appear as an OPERAND (e.g. the
214    /// consequent of `if … then ∃x. …`), not only at the top, so it extends as far right
215    /// as possible from wherever it starts — standard FOL prefix-quantifier scope.
216    fn peek_is_quantifier(&self) -> bool {
217        matches!(self.peek(), Some(Tok::Forall) | Some(Tok::Exists))
218            || matches!(
219                self.peek_kw().as_deref(),
220                Some("forall") | Some("for") | Some("exists") | Some("there") | Some("some")
221            )
222    }
223
224    // formula := quantifier | iff
225    fn parse_formula(&mut self) -> Result<ProofExpr, FormulaError> {
226        // Quantifiers: `for all <vars> , body` / `forall …` / `∀ …`, and the existential
227        // analogues. The variable list is space-separated idents up to `,`/`.` (consumed
228        // as whitespace by the tokenizer) — the comma is optional.
229        if matches!(self.peek(), Some(Tok::Forall)) {
230            self.advance();
231            return self.finish_quantifier(true);
232        }
233        if matches!(self.peek(), Some(Tok::Exists)) {
234            self.advance();
235            return self.finish_quantifier(false);
236        }
237        if self.eat_kw("forall") {
238            return self.finish_quantifier(true);
239        }
240        if self.eat_kw("for") {
241            if !self.eat_kw("all") {
242                return Err(FormulaError::new("expected 'all' after 'for'"));
243            }
244            return self.finish_quantifier(true);
245        }
246        if self.eat_kw("exists") {
247            return self.finish_quantifier(false);
248        }
249        if self.eat_kw("there") {
250            if !(self.eat_kw("exists") || self.eat_kw("is") || self.eat_kw("are")) {
251                return Err(FormulaError::new("expected 'exists' after 'there'"));
252            }
253            return self.finish_quantifier(false);
254        }
255        if self.eat_kw("some") {
256            return self.finish_quantifier(false);
257        }
258        self.parse_iff()
259    }
260
261    /// After a quantifier keyword: read the bound variables, then `,` (optional), then
262    /// the body, nesting so the FIRST variable is the OUTERMOST binder.
263    fn finish_quantifier(&mut self, universal: bool) -> Result<ProofExpr, FormulaError> {
264        let mut vars = Vec::new();
265        while let Some(Tok::Ident(s)) = self.peek() {
266            // Stop if this ident is actually the start of the body (a keyword), not a var.
267            let low = s.to_lowercase();
268            if matches!(low.as_str(), "if" | "not" | "true" | "false") {
269                break;
270            }
271            vars.push(s.clone());
272            self.advance();
273        }
274        if vars.is_empty() {
275            return Err(FormulaError::new("quantifier with no bound variables"));
276        }
277        // An optional separating comma.
278        if matches!(self.peek(), Some(Tok::Comma)) {
279            self.advance();
280        }
281        let body = self.parse_formula()?;
282        Ok(vars.into_iter().rev().fold(body, |acc, var| {
283            if universal {
284                ProofExpr::ForAll { variable: var, body: Box::new(acc) }
285            } else {
286                ProofExpr::Exists { variable: var, body: Box::new(acc) }
287            }
288        }))
289    }
290
291    // iff := implication (('iff' | '<->') implication)?
292    fn parse_iff(&mut self) -> Result<ProofExpr, FormulaError> {
293        let left = self.parse_implication()?;
294        if matches!(self.peek(), Some(Tok::Iff)) || self.peek_kw().as_deref() == Some("iff") {
295            self.advance();
296            let right = self.parse_implication()?;
297            return Ok(ProofExpr::Iff(Box::new(left), Box::new(right)));
298        }
299        Ok(left)
300    }
301
302    // implication := 'if' disjunction 'then' implication
303    //              | disjunction (('implies'|'->') implication)?     (right-assoc)
304    fn parse_implication(&mut self) -> Result<ProofExpr, FormulaError> {
305        if self.eat_kw("if") {
306            let cond = self.parse_disjunction()?;
307            if !self.eat_kw("then") {
308                return Err(FormulaError::new("expected 'then' to close an 'if'"));
309            }
310            let conseq = self.parse_implication()?;
311            return Ok(ProofExpr::Implies(Box::new(cond), Box::new(conseq)));
312        }
313        let left = self.parse_disjunction()?;
314        if matches!(self.peek(), Some(Tok::Arrow)) || self.peek_kw().as_deref() == Some("implies") {
315            self.advance();
316            let right = self.parse_implication()?;
317            return Ok(ProofExpr::Implies(Box::new(left), Box::new(right)));
318        }
319        Ok(left)
320    }
321
322    // disjunction := conjunction (('or'|'∨') conjunction)*
323    fn parse_disjunction(&mut self) -> Result<ProofExpr, FormulaError> {
324        let mut left = self.parse_conjunction()?;
325        loop {
326            if matches!(self.peek(), Some(Tok::Or)) || self.peek_kw().as_deref() == Some("or") {
327                self.advance();
328                let right = self.parse_conjunction()?;
329                left = ProofExpr::Or(Box::new(left), Box::new(right));
330            } else {
331                break;
332            }
333        }
334        Ok(left)
335    }
336
337    // conjunction := negation (('and'|'∧') negation)*
338    fn parse_conjunction(&mut self) -> Result<ProofExpr, FormulaError> {
339        let mut left = self.parse_negation()?;
340        loop {
341            if matches!(self.peek(), Some(Tok::And)) || self.peek_kw().as_deref() == Some("and") {
342                self.advance();
343                let right = self.parse_negation()?;
344                left = ProofExpr::And(Box::new(left), Box::new(right));
345            } else {
346                break;
347            }
348        }
349        Ok(left)
350    }
351
352    // negation := ('not'|'¬') negation | atom
353    fn parse_negation(&mut self) -> Result<ProofExpr, FormulaError> {
354        if matches!(self.peek(), Some(Tok::Not)) || self.peek_kw().as_deref() == Some("not") {
355            self.advance();
356            let inner = self.parse_negation()?;
357            return Ok(ProofExpr::Not(Box::new(inner)));
358        }
359        self.parse_atom()
360    }
361
362    // atom := '(' formula ')'
363    //       | 'true' | 'false'
364    //       | ident '(' term (',' term)* ')'        (predicate / relation)
365    //       | term '=' term                          (identity)
366    //       | ident                                  (0-ary propositional atom)
367    fn parse_atom(&mut self) -> Result<ProofExpr, FormulaError> {
368        // A quantifier as an operand (`… then ∃x. …`, `… and ∀y. …`) — delegate to the
369        // full-formula entry, which consumes the prefix and grabs its body rightward.
370        if self.peek_is_quantifier() {
371            return self.parse_formula();
372        }
373
374        if matches!(self.peek(), Some(Tok::LParen)) {
375            self.advance();
376            let inner = self.parse_formula()?;
377            if !matches!(self.advance(), Some(Tok::RParen)) {
378                return Err(FormulaError::new("expected ')'"));
379            }
380            return Ok(inner);
381        }
382
383        let name = match self.advance() {
384            Some(Tok::Ident(s)) => s.clone(),
385            other => {
386                return Err(FormulaError::new(format!(
387                    "expected an atom, found {other:?}"
388                )))
389            }
390        };
391
392        match name.to_lowercase().as_str() {
393            "true" => return Ok(ProofExpr::Atom("True".to_string())),
394            "false" => return Ok(ProofExpr::Atom("False".to_string())),
395            _ => {}
396        }
397
398        // Relation application: `Name(t1, t2, …)`.
399        if matches!(self.peek(), Some(Tok::LParen)) {
400            self.advance();
401            let mut args = Vec::new();
402            if !matches!(self.peek(), Some(Tok::RParen)) {
403                loop {
404                    args.push(self.parse_term()?);
405                    if matches!(self.peek(), Some(Tok::Comma)) {
406                        self.advance();
407                        continue;
408                    }
409                    break;
410                }
411            }
412            if !matches!(self.advance(), Some(Tok::RParen)) {
413                return Err(FormulaError::new("expected ')' to close a relation's arguments"));
414            }
415            return Ok(ProofExpr::Predicate { name, args, world: None });
416        }
417
418        // Identity: `a = b`.
419        if matches!(self.peek(), Some(Tok::Eq)) {
420            self.advance();
421            let rhs = self.parse_term()?;
422            return Ok(ProofExpr::Identity(ident_to_term(&name), rhs));
423        }
424
425        // A bare identifier is a 0-ary propositional atom.
426        Ok(ProofExpr::Atom(name))
427    }
428
429    fn parse_term(&mut self) -> Result<ProofTerm, FormulaError> {
430        match self.advance() {
431            Some(Tok::Ident(s)) => Ok(ident_to_term(s)),
432            other => Err(FormulaError::new(format!("expected a term, found {other:?}"))),
433        }
434    }
435}
436
437/// An uppercase- or digit-leading identifier is a CONSTANT; a lowercase-leading one is a
438/// VARIABLE — the standard FOL reading the verifier already uses.
439fn ident_to_term(s: &str) -> ProofTerm {
440    let leads_constant = s
441        .chars()
442        .next()
443        .map(|c| c.is_uppercase() || c.is_ascii_digit())
444        .unwrap_or(false);
445    if leads_constant {
446        ProofTerm::Constant(s.to_string())
447    } else {
448        ProofTerm::Variable(s.to_string())
449    }
450}