Skip to main content

qualia_core_db/modalities/logic/
n3_parser.rs

1//! Native, zero-copy N3 (Notation3) parser.
2//!
3//! Two complementary front-ends over the same tokenizer:
4//!
5//! * **Borrowing AST** ([`Formula`]/[`Rule`], `Vec`-backed) — the convenient
6//!   cold-path form consumed by [`crate::modalities::logic::n3_compiler`], which
7//!   immediately lowers it to a zero-heap `CompiledRule`.
8//! * **Zero-allocation AST** ([`StackFormula`]/[`StackRule`], fixed arrays) +
9//!   [`N3Parser::parse_all_zero_heap`] — parses N3 with **no heap allocation**
10//!   at all (proven by a `dhat` test), for edge hardware and the hot path.
11//!
12//! Supported N3 surface: triples with `;` (predicate lists) and `,` (object
13//! lists); the `a` (rdf:type) keyword, kept as the engine's bare `a` token;
14//! implication rules
15//! (`=>` strict, `~>` defeasible, `^>` defeater, ` -o ` linear) with optional
16//! `[id]` and `(weight)` annotations; `{ … }` **formula quoting / reification**
17//! (a quoted graph used as a term, identified by the canonical hash of its
18//! text); `#asp { … }` and `qualia:diffuse { … }` blocks; `#` comments.
19//!
20//! Resource caps (anti-DoS): brace nesting is bounded by
21//! [`MAX_PARSE_BRACE_DEPTH`] and statement count by [`MAX_PARSE_STATEMENTS`];
22//! a quoted/parsed formula yields at most [`MAX_STACK_TRIPLES`] triples. The
23//! *evaluation* caps (forward-chaining fixpoint rounds, premise depth) live in
24//! [`crate::webizen`] (`fire_guard_rules`).
25
26use std::fmt;
27
28/// Decode one UTF-8 character at byte offset `i` in `bytes`, returning
29/// `(char, byte_width)`. Falls back to the raw byte for invalid UTF-8
30/// (never panics). Used for Unicode-aware whitespace detection.
31#[inline]
32fn decode_utf8_char(bytes: &[u8], i: usize) -> (char, usize) {
33    let b = bytes[i];
34    if b < 0x80 {
35        return (b as char, 1);
36    }
37    let remaining = &bytes[i..];
38    match std::str::from_utf8(remaining) {
39        Ok(s) => {
40            let c = s.chars().next().unwrap_or(b as char);
41            (c, c.len_utf8())
42        }
43        Err(e) => {
44            let valid_len = e.valid_up_to();
45            if valid_len > 0 {
46                let s = std::str::from_utf8(&remaining[..valid_len]).unwrap();
47                let c = s.chars().next().unwrap();
48                (c, c.len_utf8())
49            } else {
50                (b as char, 1)
51            }
52        }
53    }
54}
55
56/// Max triples in a zero-allocation [`StackFormula`] (matches the compiler's
57/// `CompiledFormula` capacity).
58pub const MAX_STACK_TRIPLES: usize = 8;
59/// Max `{` nesting depth accepted by [`N3Parser::parse_all`] before failing
60/// closed — prevents pathological-input parser state explosion.
61pub const MAX_PARSE_BRACE_DEPTH: usize = 16;
62/// Max top-level statements accepted in a single parse — anti-DoS bound.
63pub const MAX_PARSE_STATEMENTS: usize = 65_536;
64
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub enum Term<'a> {
67    Uri(&'a str),
68    Variable(&'a str),
69    Literal(&'a str),
70    /// A quoted N3 formula `{ … }` used as a term (graph quoting / reification).
71    /// Holds the trimmed inner text; its identity is [`q_hash_formula`] of that
72    /// text, so an nquin can refer to (reason about) another statement.
73    Formula(&'a str),
74}
75
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct Triple<'a> {
78    pub subject: Term<'a>,
79    pub predicate: Term<'a>,
80    pub object: Term<'a>,
81}
82
83/// Heap-backed formula (cold-path convenience; the compiler lowers it to a
84/// fixed-size `CompiledFormula`).
85#[derive(Debug, Clone, PartialEq)]
86pub struct Formula<'a> {
87    pub triples: Vec<Triple<'a>>,
88}
89
90/// Zero-allocation formula: borrowed triples in a fixed stack array.
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct StackFormula<'a> {
93    pub triples: [Triple<'a>; MAX_STACK_TRIPLES],
94    pub len: usize,
95}
96
97impl<'a> StackFormula<'a> {
98    #[inline]
99    pub fn new() -> Self {
100        Self {
101            triples: [empty_triple(); MAX_STACK_TRIPLES],
102            len: 0,
103        }
104    }
105    /// The populated prefix.
106    #[inline]
107    pub fn as_slice(&self) -> &[Triple<'a>] {
108        &self.triples[..self.len]
109    }
110}
111
112impl Default for StackFormula<'_> {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum RuleType {
120    Strict,
121    Defeasible,
122    Defeater,
123    Linear,
124}
125
126/// Heap-backed rule (cold-path convenience).
127#[derive(Debug, Clone, PartialEq)]
128pub struct Rule<'a> {
129    pub id: Option<&'a str>,
130    pub rule_type: RuleType,
131    pub weight: Option<f32>,
132    pub premise: Formula<'a>,
133    pub conclusion: Formula<'a>,
134}
135
136/// Zero-allocation rule.
137#[derive(Debug, Clone, Copy, PartialEq)]
138pub struct StackRule<'a> {
139    pub id: Option<&'a str>,
140    pub rule_type: RuleType,
141    pub weight: Option<f32>,
142    pub premise: StackFormula<'a>,
143    pub conclusion: StackFormula<'a>,
144}
145
146/// Streamed event from the heap front-end ([`N3Parser::parse_all`]).
147#[derive(Debug)]
148pub enum N3Event<'a> {
149    StaticTriple(Triple<'a>),
150    LogicRule(Rule<'a>),
151    AspBlock(&'a str),
152    DiffuseBlock(&'a str),
153}
154
155/// Streamed event from the zero-allocation front-end
156/// ([`N3Parser::parse_all_zero_heap`]). `Copy`, no heap.
157#[derive(Debug, Clone, Copy)]
158pub enum StackEvent<'a> {
159    StaticTriple(Triple<'a>),
160    LogicRule(StackRule<'a>),
161    AspBlock(&'a str),
162    DiffuseBlock(&'a str),
163}
164
165#[derive(Debug, Clone)]
166pub struct N3ParserError(pub &'static str);
167
168impl fmt::Display for N3ParserError {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        write!(f, "{}", self.0)
171    }
172}
173
174impl std::error::Error for N3ParserError {}
175
176#[inline]
177fn empty_triple<'a>() -> Triple<'a> {
178    Triple {
179        subject: Term::Uri(""),
180        predicate: Term::Uri(""),
181        object: Term::Uri(""),
182    }
183}
184
185/// Canonical 64-bit FNV-1a hash of a quoted-formula's text, with runs of
186/// Unicode whitespace collapsed to a single space (and leading/trailing
187/// whitespace dropped). This gives a **stable identity** for a quoted statement
188/// so that `{ :a :b :c }` and `{  :a  :b  :c  }` denote the same node — the
189/// handle other nquins use to reason about that statement (reification).
190/// Zero-allocation.
191///
192/// Unicode-aware: handles CJK ideographic space (U+3000), non-breaking space
193/// (U+00A0), and all other `char::is_whitespace` code points, not just ASCII.
194pub fn q_hash_formula(text: &str) -> u64 {
195    const OFFSET: u64 = 0xcbf29ce484222325;
196    const PRIME: u64 = 0x0000_0100_0000_01B3;
197    let mut h = OFFSET;
198    // Defer the separator space until a following token confirms it, so leading
199    // AND trailing whitespace are both dropped (only interior runs collapse).
200    let mut pending_space = false;
201    let mut started = false;
202    for c in text.chars() {
203        if c.is_whitespace() {
204            if started {
205                pending_space = true;
206            }
207        } else {
208            if pending_space {
209                h ^= 0x20;
210                h = h.wrapping_mul(PRIME);
211                pending_space = false;
212            }
213            // Hash the UTF-8 bytes of the character, not just the low byte.
214            for &b in c.encode_utf8(&mut [0u8; 4]).as_bytes() {
215                h ^= b as u64;
216                h = h.wrapping_mul(PRIME);
217            }
218            started = true;
219        }
220    }
221    h
222}
223
224/// Map a parsed N3 [`Term`] to its 64-bit quin hash (variables return `None`).
225pub fn term_uri_hash(term: &Term<'_>) -> Option<u64> {
226    match term {
227        Term::Uri(uri) => Some(crate::q_hash(uri)),
228        Term::Literal(lit) => Some(crate::q_hash(lit)),
229        Term::Formula(s) => Some(q_hash_formula(s)),
230        Term::Variable(_) => None,
231    }
232}
233
234/// Tokenizer over one N3 statement-block. Yields, in order:
235/// * a `{ … }` quoted formula (balanced braces) as a single token (incl. braces),
236/// * a `"…"` string literal as a single token (incl. quotes),
237/// * the punctuation tokens `;`, `,`, `.` (one char each),
238/// * otherwise a run up to the next whitespace / punctuation / `{` / `"`.
239///
240/// A `.` between two digits (a decimal, e.g. `3.14`) is kept inside the token.
241struct TripleTokenizer<'a> {
242    s: &'a str,
243    b: &'a [u8],
244    i: usize,
245}
246
247impl<'a> TripleTokenizer<'a> {
248    fn new(s: &'a str) -> Self {
249        Self {
250            s,
251            b: s.as_bytes(),
252            i: 0,
253        }
254    }
255}
256
257impl<'a> Iterator for TripleTokenizer<'a> {
258    type Item = &'a str;
259
260    fn next(&mut self) -> Option<&'a str> {
261        let len = self.b.len();
262        // Skip whitespace and `#...\n` comments (a comment is only a comment when
263        // it starts a token - a `#` inside a `<...>` URI or string literal is
264        // consumed as part of that token below). Unicode-aware: handles CJK
265        // ideographic space (U+3000), non-breaking space (U+00A0), etc.
266        loop {
267            while self.i < len {
268                let (c, w) = decode_utf8_char(self.b, self.i);
269                if c.is_whitespace() {
270                    self.i += w;
271                } else {
272                    break;
273                }
274            }
275            if self.i < len && self.b[self.i] == b'#' {
276                while self.i < len && self.b[self.i] != b'\n' {
277                    self.i += 1;
278                }
279                continue;
280            }
281            break;
282        }
283        if self.i >= len {
284            return None;
285        }
286        let start = self.i;
287        let c = self.b[self.i];
288
289        // Quoted formula: balanced braces.
290        if c == b'{' {
291            let mut depth = 0i32;
292            while self.i < len {
293                match self.b[self.i] {
294                    b'{' => depth += 1,
295                    b'}' => {
296                        depth -= 1;
297                        if depth == 0 {
298                            self.i += 1;
299                            break;
300                        }
301                    }
302                    _ => {}
303                }
304                self.i += 1;
305            }
306            return Some(&self.s[start..self.i]);
307        }
308
309        // String literal.
310        if c == b'"' {
311            self.i += 1;
312            while self.i < len && self.b[self.i] != b'"' {
313                self.i += 1;
314            }
315            if self.i < len {
316                self.i += 1; // include closing quote
317            }
318            return Some(&self.s[start..self.i]);
319        }
320
321        // Single-char punctuation.
322        if c == b';' || c == b',' || c == b'.' {
323            self.i += 1;
324            return Some(&self.s[start..self.i]);
325        }
326
327        // General token.
328        while self.i < len {
329            let d = self.b[self.i];
330            if d == b'.' {
331                // Keep a decimal point (digit '.' digit) inside the token.
332                let prev_digit = self.i > start && self.b[self.i - 1].is_ascii_digit();
333                let next_digit = self.i + 1 < len && self.b[self.i + 1].is_ascii_digit();
334                if prev_digit && next_digit {
335                    self.i += 1;
336                    continue;
337                }
338                break;
339            }
340            if d.is_ascii_whitespace() || d == b';' || d == b',' || d == b'{' || d == b'"' {
341                break;
342            }
343            // Also break on Unicode whitespace (e.g. U+3000 ideographic space).
344            if (d & 0x80) != 0 {
345                let (ch, _) = decode_utf8_char(self.b, self.i);
346                if ch.is_whitespace() {
347                    break;
348                }
349            }
350            self.i += 1;
351        }
352        Some(&self.s[start..self.i])
353    }
354}
355
356/// Parse the triples of one block, emitting each via `emit`. The block may be a
357/// bare formula body or a `{ … }`-wrapped one; multiple `.`-separated statements
358/// are handled, as are `;` (predicate) and `,` (object) lists. Zero-allocation.
359///
360/// `emit` returns `Ok(true)` to continue, `Ok(false)` to stop early (e.g. a
361/// caller buffer filled), or `Err` to abort.
362fn for_each_triple<'a>(
363    block: &'a str,
364    mut emit: impl FnMut(Triple<'a>) -> Result<bool, N3ParserError>,
365) -> Result<(), N3ParserError> {
366    let mut s = block.trim();
367    if s.starts_with('{') && s.ends_with('}') {
368        s = s[1..s.len() - 1].trim();
369    }
370
371    let mut subject: Option<Term<'a>> = None;
372    let mut predicate: Option<Term<'a>> = None;
373
374    for tok in TripleTokenizer::new(s) {
375        match tok {
376            "." => {
377                subject = None;
378                predicate = None;
379            }
380            ";" => {
381                predicate = None; // same subject, new predicate
382            }
383            "," => {
384                // same subject + predicate, new object: state already correct.
385            }
386            node => {
387                let term = N3Parser::parse_term(node);
388                if subject.is_none() {
389                    subject = Some(term);
390                } else if predicate.is_none() {
391                    predicate = Some(term);
392                } else {
393                    let triple = Triple {
394                        subject: subject.unwrap(),
395                        predicate: predicate.unwrap(),
396                        object: term,
397                    };
398                    if !emit(triple)? {
399                        return Ok(());
400                    }
401                    // Stay ready for `,` (another object) / `;` (new predicate) /
402                    // `.` (new statement); a bare following node is treated as a
403                    // comma-implied object.
404                }
405            }
406        }
407    }
408    Ok(())
409}
410
411/// A highly capable, native N3 parser over a borrowed `&str` (zero-copy terms).
412pub struct N3Parser<'a> {
413    text: &'a str,
414}
415
416impl<'a> N3Parser<'a> {
417    pub fn new(text: &'a str) -> Self {
418        N3Parser { text }
419    }
420
421    // ── Heap front-end (cold-path convenience) ──────────────────────────────
422
423    pub fn parse_all<F>(&mut self, callback: F) -> Result<(), N3ParserError>
424    where
425        F: FnMut(N3Event<'a>) -> Result<(), N3ParserError>,
426    {
427        self.scan(callback, dispatch_statement_heap, emit_block_heap)
428    }
429
430    /// Zero-allocation streaming parse: emits [`StackEvent`]s with no heap use.
431    pub fn parse_all_zero_heap<F>(&mut self, callback: F) -> Result<(), N3ParserError>
432    where
433        F: FnMut(StackEvent<'a>) -> Result<(), N3ParserError>,
434    {
435        self.scan(callback, dispatch_statement_stack, emit_block_stack)
436    }
437
438    /// Shared statement-splitter for both front-ends. Tracks comments, the two
439    /// special `{ … }` blocks, brace depth (capped), and `.`-termination at
440    /// brace depth 0, dispatching each statement via `dispatch` and each special
441    /// block via `emit_block`. `F` (the caller's event type) is opaque here —
442    /// only the per-front-end function pointers know how to build events.
443    fn scan<F>(
444        &mut self,
445        mut callback: F,
446        dispatch: fn(&'a str, &mut F) -> Result<(), N3ParserError>,
447        emit_block: fn(BlockKind, &'a str, &mut F) -> Result<(), N3ParserError>,
448    ) -> Result<(), N3ParserError> {
449        let bytes = self.text.as_bytes();
450        let len = bytes.len();
451
452        let mut i = 0;
453        let mut stmt_start = 0;
454        let mut brace_depth: i32 = 0;
455        let mut in_comment = false;
456        let mut in_string = false;
457        let mut statements = 0usize;
458
459        while i < len {
460            let c = bytes[i] as char;
461
462            if in_comment {
463                if c == '\n' {
464                    in_comment = false;
465                }
466                i += 1;
467                continue;
468            }
469
470            // Inside a `"…"` string literal, structural characters (`.`, `#`, `{`,
471            // `}`) are data, not delimiters — e.g. `"alice@example.org"` must not
472            // be split at the dot. (Comments are handled above; a `"` in a comment
473            // never reaches here.)
474            if in_string {
475                if c == '"' {
476                    in_string = false;
477                }
478                i += 1;
479                continue;
480            }
481            if c == '"' {
482                in_string = true;
483                i += 1;
484                continue;
485            }
486
487            if c == '#' {
488                if self.text[i..].starts_with("#asp {") {
489                    let end = self.text[i..].find('}').unwrap_or(self.text[i..].len());
490                    emit_block(
491                        BlockKind::Asp,
492                        self.text[i + 6..i + end].trim(),
493                        &mut callback,
494                    )?;
495                    i += end + 1;
496                    stmt_start = i;
497                    continue;
498                } else {
499                    in_comment = true;
500                    i += 1;
501                    continue;
502                }
503            }
504
505            // Gate on the ASCII lead byte 'q' so `self.text[i..]` is only sliced
506            // on a UTF-8 char boundary (continuation bytes of multi-byte chars
507            // in URIs/strings/comments must never reach a `str` slice).
508            if c == 'q' && self.text[i..].starts_with("qualia:diffuse {") {
509                let end = self.text[i..].find('}').unwrap_or(self.text[i..].len());
510                emit_block(
511                    BlockKind::Diffuse,
512                    self.text[i + 16..i + end].trim(),
513                    &mut callback,
514                )?;
515                i += end + 1;
516                stmt_start = i;
517                continue;
518            }
519
520            if c == '{' {
521                brace_depth += 1;
522                if brace_depth as usize > MAX_PARSE_BRACE_DEPTH {
523                    return Err(N3ParserError(
524                        "N3 brace nesting exceeds MAX_PARSE_BRACE_DEPTH",
525                    ));
526                }
527            }
528            if c == '}' {
529                brace_depth -= 1;
530                if brace_depth < 0 {
531                    return Err(N3ParserError("unbalanced '}' in N3 input"));
532                }
533            }
534
535            if c == '.' && brace_depth <= 0 {
536                let stmt = self.text[stmt_start..=i].trim();
537                statements += 1;
538                if statements > MAX_PARSE_STATEMENTS {
539                    return Err(N3ParserError(
540                        "N3 statement count exceeds MAX_PARSE_STATEMENTS",
541                    ));
542                }
543                dispatch(stmt, &mut callback)?;
544                stmt_start = i + 1;
545            }
546
547            i += 1;
548        }
549
550        if brace_depth != 0 {
551            return Err(N3ParserError("unbalanced '{' in N3 input"));
552        }
553
554        let rem = self.text[stmt_start..].trim();
555        if !rem.is_empty() && !rem.starts_with('@') && !rem.starts_with('#') {
556            dispatch(rem, &mut callback)?;
557        }
558
559        Ok(())
560    }
561
562    // ── Zero-allocation helpers ─────────────────────────────────────────────
563
564    /// Parse a block's triples into a caller-provided buffer; returns the count
565    /// written (capped at `out.len()`). Zero-allocation.
566    pub fn parse_triples_into(block: &'a str, out: &mut [Triple<'a>]) -> usize {
567        let mut n = 0usize;
568        let _ = for_each_triple(block, |t| {
569            if n < out.len() {
570                out[n] = t;
571                n += 1;
572                Ok(n < out.len())
573            } else {
574                Ok(false)
575            }
576        });
577        n
578    }
579
580    /// Parse one rule line into a zero-allocation [`StackRule`].
581    pub fn parse_rule_zero_heap(line: &'a str) -> Option<StackRule<'a>> {
582        let (id, weight, rule_type, premise_str, conclusion_str) = split_rule(line)?;
583        let mut premise = StackFormula::new();
584        premise.len = Self::parse_triples_into(premise_str, &mut premise.triples);
585        let mut conclusion = StackFormula::new();
586        conclusion.len = Self::parse_triples_into(conclusion_str, &mut conclusion.triples);
587        Some(StackRule {
588            id,
589            rule_type,
590            weight,
591            premise,
592            conclusion,
593        })
594    }
595
596    // ── Heap helpers (kept for existing consumers) ──────────────────────────
597
598    fn parse_rule(line: &'a str) -> Option<Rule<'a>> {
599        let (id, weight, rule_type, premise_str, conclusion_str) = split_rule(line)?;
600        Some(Rule {
601            id,
602            rule_type,
603            weight,
604            premise: Formula {
605                triples: Self::parse_formula_triples(premise_str),
606            },
607            conclusion: Formula {
608                triples: Self::parse_formula_triples(conclusion_str),
609            },
610        })
611    }
612
613    fn parse_formula_triples(block: &'a str) -> Vec<Triple<'a>> {
614        let mut triples = Vec::new();
615        let _ = for_each_triple(block, |t| {
616            triples.push(t);
617            Ok(true)
618        });
619        triples
620    }
621
622    fn parse_term(s: &'a str) -> Term<'a> {
623        let s = s.trim();
624        if s.is_empty() {
625            return Term::Uri("");
626        }
627        // NOTE: the N3 `a` keyword (rdf:type) is left as the bare token `a`,
628        // which is the engine's established type predicate (`q_hash("a")`, used
629        // uniformly by the agency/values guards and fact ingestion). It is *not*
630        // expanded to `rdf:type` here — doing so would desync rules from facts.
631        if let Some(rest) = s.strip_prefix('?') {
632            let _ = rest;
633            return Term::Variable(s);
634        }
635        if s.starts_with('{') {
636            let inner = s
637                .strip_prefix('{')
638                .and_then(|x| x.strip_suffix('}'))
639                .unwrap_or(s)
640                .trim();
641            return Term::Formula(inner);
642        }
643        if s.starts_with('"') {
644            // Strip quotes so literal VALUES are comparable / numerically parseable.
645            return Term::Literal(s.trim_matches('"'));
646        }
647        if s.parse::<f64>().is_ok() {
648            return Term::Literal(s);
649        }
650        Term::Uri(s)
651    }
652}
653
654/// True if a statement looks like an implication rule.
655fn looks_like_rule(s: &str) -> bool {
656    s.contains("=>") || s.contains("~>") || s.contains("^>") || s.contains(" -o ")
657}
658
659/// Split a rule line into `(id, weight, type, premise_str, conclusion_str)`,
660/// all zero-copy slices. Returns `None` if no arrow is present.
661fn split_rule(line: &str) -> Option<(Option<&str>, Option<f32>, RuleType, &str, &str)> {
662    let mut clean = line.trim();
663    let mut id = None;
664    let mut weight = None;
665
666    if clean.starts_with('[') {
667        if let Some(end) = clean.find(']') {
668            id = Some(clean[1..end].trim());
669            clean = clean[end + 1..].trim();
670        }
671    }
672    if clean.starts_with('(') {
673        if let Some(end) = clean.find(')') {
674            if let Ok(w) = clean[1..end].trim().parse::<f32>() {
675                weight = Some(w);
676            }
677            clean = clean[end + 1..].trim();
678        }
679    }
680
681    let (rule_type, arrow_len, arrow_idx) = if let Some(idx) = clean.find("=>") {
682        (RuleType::Strict, 2, idx)
683    } else if let Some(idx) = clean.find("~>") {
684        (RuleType::Defeasible, 2, idx)
685    } else if let Some(idx) = clean.find("^>") {
686        (RuleType::Defeater, 2, idx)
687    } else if let Some(idx) = clean.find(" -o ") {
688        (RuleType::Linear, 4, idx)
689    } else {
690        return None;
691    };
692
693    let premise = clean[..arrow_idx].trim();
694    let conclusion = clean[arrow_idx + arrow_len..].trim().trim_end_matches('.');
695    Some((id, weight, rule_type, premise, conclusion))
696}
697
698fn trim_leading_comment_lines(mut s: &str) -> &str {
699    loop {
700        let trimmed = s.trim_start();
701        let Some(rest) = trimmed.strip_prefix('#') else {
702            return trimmed;
703        };
704        let Some(newline) = rest.find('\n') else {
705            return "";
706        };
707        s = &rest[newline + 1..];
708    }
709}
710
711// ── Statement dispatchers (one per front-end) ───────────────────────────────
712
713enum BlockKind {
714    Asp,
715    Diffuse,
716}
717
718fn emit_block_heap<'a, F>(
719    kind: BlockKind,
720    body: &'a str,
721    callback: &mut F,
722) -> Result<(), N3ParserError>
723where
724    F: FnMut(N3Event<'a>) -> Result<(), N3ParserError>,
725{
726    match kind {
727        BlockKind::Asp => callback(N3Event::AspBlock(body)),
728        BlockKind::Diffuse => callback(N3Event::DiffuseBlock(body)),
729    }
730}
731
732fn emit_block_stack<'a, F>(
733    kind: BlockKind,
734    body: &'a str,
735    callback: &mut F,
736) -> Result<(), N3ParserError>
737where
738    F: FnMut(StackEvent<'a>) -> Result<(), N3ParserError>,
739{
740    match kind {
741        BlockKind::Asp => callback(StackEvent::AspBlock(body)),
742        BlockKind::Diffuse => callback(StackEvent::DiffuseBlock(body)),
743    }
744}
745
746fn dispatch_statement_heap<'a, F>(stmt: &'a str, callback: &mut F) -> Result<(), N3ParserError>
747where
748    F: FnMut(N3Event<'a>) -> Result<(), N3ParserError>,
749{
750    let s = trim_leading_comment_lines(stmt);
751    if s.is_empty() {
752        return Ok(());
753    }
754    if looks_like_rule(s) {
755        if let Some(rule) = N3Parser::parse_rule(s) {
756            return callback(N3Event::LogicRule(rule));
757        }
758    }
759    let triples = N3Parser::parse_formula_triples(s.trim_end_matches('.'));
760    for triple in triples {
761        callback(N3Event::StaticTriple(triple))?;
762    }
763    Ok(())
764}
765
766fn dispatch_statement_stack<'a, F>(stmt: &'a str, callback: &mut F) -> Result<(), N3ParserError>
767where
768    F: FnMut(StackEvent<'a>) -> Result<(), N3ParserError>,
769{
770    let s = trim_leading_comment_lines(stmt);
771    if s.is_empty() {
772        return Ok(());
773    }
774    if looks_like_rule(s) {
775        if let Some(rule) = N3Parser::parse_rule_zero_heap(s) {
776            return callback(StackEvent::LogicRule(rule));
777        }
778    }
779    let mut err: Option<N3ParserError> = None;
780    for_each_triple(s.trim_end_matches('.'), |t| {
781        match callback(StackEvent::StaticTriple(t)) {
782            Ok(()) => Ok(true),
783            Err(e) => {
784                err = Some(e);
785                Ok(false)
786            }
787        }
788    })?;
789    if let Some(e) = err {
790        return Err(e);
791    }
792    Ok(())
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    // ── Faithful parsing (item 2) ───────────────────────────────────────────
800
801    #[test]
802    fn keyword_a_is_the_engine_type_token() {
803        // The N3 `a` keyword is kept as the bare token the engine uses uniformly
804        // for rdf:type (`q_hash("a")`) — NOT expanded to `rdf:type`, which would
805        // desync parsed rules from facts asserted with `vh("a")`.
806        let mut buf = [empty_triple(); MAX_STACK_TRIPLES];
807        let n = N3Parser::parse_triples_into(":x a :Y .", &mut buf);
808        assert_eq!(n, 1);
809        assert_eq!(buf[0].predicate, Term::Uri("a"));
810        assert_eq!(buf[0].subject, Term::Uri(":x"));
811        assert_eq!(buf[0].object, Term::Uri(":Y"));
812    }
813
814    #[test]
815    fn object_lists_and_predicate_lists() {
816        let mut buf = [empty_triple(); MAX_STACK_TRIPLES];
817        // comma = object list, semicolon = predicate list
818        let n = N3Parser::parse_triples_into(":x :p :a, :b ; :q :c .", &mut buf);
819        assert_eq!(n, 3);
820        assert_eq!(
821            buf[0],
822            Triple {
823                subject: Term::Uri(":x"),
824                predicate: Term::Uri(":p"),
825                object: Term::Uri(":a")
826            }
827        );
828        assert_eq!(
829            buf[1],
830            Triple {
831                subject: Term::Uri(":x"),
832                predicate: Term::Uri(":p"),
833                object: Term::Uri(":b")
834            }
835        );
836        assert_eq!(
837            buf[2],
838            Triple {
839                subject: Term::Uri(":x"),
840                predicate: Term::Uri(":q"),
841                object: Term::Uri(":c")
842            }
843        );
844    }
845
846    #[test]
847    fn leading_comment_before_rule_preserves_premise() {
848        let text = r#"
849# (G1) Corporate-capture guard.
850{ ?c a values:CorporatePerson ; values:claims ?r .
851  ?r a values:Right ; values:heldBy values:NaturalPerson
852} => { ?c values:flag values:PersonhoodCategoryError } .
853"#;
854        let mut rules = Vec::new();
855        let mut parser = N3Parser::new(text);
856        parser
857            .parse_all(|ev| {
858                if let N3Event::LogicRule(rule) = ev {
859                    rules.push(rule);
860                }
861                Ok(())
862            })
863            .unwrap();
864
865        assert_eq!(rules.len(), 1);
866        assert_eq!(rules[0].premise.triples.len(), 4);
867        assert_eq!(rules[0].conclusion.triples.len(), 1);
868        assert_eq!(rules[0].premise.triples[0].subject, Term::Variable("?c"));
869        assert_eq!(rules[0].premise.triples[0].predicate, Term::Uri("a"));
870        assert_eq!(
871            rules[0].premise.triples[0].object,
872            Term::Uri("values:CorporatePerson")
873        );
874    }
875
876    #[test]
877    fn decimal_literal_is_one_token() {
878        let mut buf = [empty_triple(); MAX_STACK_TRIPLES];
879        let n = N3Parser::parse_triples_into(":x :p 3.14 .", &mut buf);
880        assert_eq!(n, 1);
881        assert_eq!(buf[0].object, Term::Literal("3.14"));
882    }
883
884    #[test]
885    fn parses_implication_rule_zero_heap() {
886        let rule = N3Parser::parse_rule_zero_heap("{ ?x a ?y } => { ?y a ?z } .").unwrap();
887        assert_eq!(rule.rule_type, RuleType::Strict);
888        assert_eq!(rule.premise.len, 1);
889        assert_eq!(rule.conclusion.len, 1);
890        let p = rule.premise.triples[0];
891        assert_eq!(p.subject, Term::Variable("?x"));
892        assert_eq!(p.predicate, Term::Uri("a"));
893        assert_eq!(p.object, Term::Variable("?y"));
894    }
895
896    // ── Quoting / reification (item 3) ──────────────────────────────────────
897
898    #[test]
899    fn quoted_formula_term_is_captured() {
900        let mut buf = [empty_triple(); MAX_STACK_TRIPLES];
901        let n = N3Parser::parse_triples_into("{ :alice :says :x } :trustedBy :bob .", &mut buf);
902        assert_eq!(n, 1);
903        match buf[0].subject {
904            Term::Formula(body) => assert!(body.contains(":alice :says :x")),
905            other => panic!("expected a quoted formula subject, got {other:?}"),
906        }
907        assert_eq!(buf[0].predicate, Term::Uri(":trustedBy"));
908        assert_eq!(buf[0].object, Term::Uri(":bob"));
909    }
910
911    #[test]
912    fn formula_with_internal_period_is_one_term() {
913        let mut buf = [empty_triple(); MAX_STACK_TRIPLES];
914        let n = N3Parser::parse_triples_into("{ :a :b :c . :d :e :f } :g :h .", &mut buf);
915        assert_eq!(n, 1);
916        assert!(matches!(buf[0].subject, Term::Formula(_)));
917        assert_eq!(buf[0].object, Term::Uri(":h"));
918    }
919
920    #[test]
921    fn reification_handle_is_whitespace_canonical() {
922        // The same statement with different spacing denotes the same node.
923        assert_eq!(q_hash_formula(":a :b :c"), q_hash_formula("  :a   :b  :c "));
924        assert_ne!(q_hash_formula(":a :b :c"), q_hash_formula(":a :b :d"));
925    }
926
927    // ── Resource caps (item 4, parser level) ────────────────────────────────
928
929    #[test]
930    fn rejects_excessive_brace_nesting() {
931        let deep = "{".repeat(MAX_PARSE_BRACE_DEPTH + 2);
932        let mut parser = N3Parser::new(&deep);
933        let r = parser.parse_all(|_| Ok(()));
934        assert!(r.is_err());
935    }
936
937    #[test]
938    fn rejects_unbalanced_braces() {
939        let mut parser = N3Parser::new("} :a :b :c .");
940        assert!(parser.parse_all(|_| Ok(())).is_err());
941    }
942
943    #[test]
944    fn parses_multibyte_utf8_without_panicking() {
945        // Multi-byte UTF-8 outside comments (literals, URIs) must not cause a
946        // mid-character `str` slice panic in the byte-scanning loop.
947        let doc = ":x :label \"café — déjà ➜ vu\" .\n\
948                   <http://例え.example/Ω> :note \":naïve\" .\n";
949        let mut parser = N3Parser::new(doc);
950        let mut n = 0usize;
951        parser
952            .parse_all(|ev| {
953                if let N3Event::StaticTriple(_) = ev {
954                    n += 1;
955                }
956                Ok(())
957            })
958            .unwrap();
959        assert!(n >= 2, "expected both UTF-8 triples, got {n}");
960    }
961
962    // ── Zero-allocation guarantee (item 1) ──────────────────────────────────
963
964    #[test]
965    fn parse_all_zero_heap_allocates_nothing() {
966        let doc = "\
967            :alice a :Person .\n\
968            :alice :knows :bob, :carol .\n\
969            { ?x a :Person } => { ?x a :Agent } .\n\
970            { :alice :says :hi } :assertedBy :alice .\n";
971        let _profiler = dhat::Profiler::builder().testing().build();
972
973        let mut parser = N3Parser::new(doc);
974        let mut triples = 0usize;
975        let mut rules = 0usize;
976        parser
977            .parse_all_zero_heap(|ev| {
978                match ev {
979                    StackEvent::StaticTriple(_) => triples += 1,
980                    StackEvent::LogicRule(_) => rules += 1,
981                    _ => {}
982                }
983                Ok(())
984            })
985            .unwrap();
986
987        let stats = dhat::HeapStats::get();
988        assert_eq!(
989            stats.curr_blocks, 0,
990            "parse_all_zero_heap must not allocate"
991        );
992        assert_eq!(stats.curr_bytes, 0);
993        assert!(triples >= 4, "expected the static + object-list triples");
994        assert_eq!(rules, 1);
995    }
996
997    // ── End-to-end: parse → compile → bytecode (item 2) ─────────────────────
998
999    #[test]
1000    fn implication_compiles_to_sentinel_bytecode() {
1001        use crate::modalities::logic::n3_compiler::{
1002            compile_rule_to_opcodes, compile_rule_to_zero_heap, MAX_COMPILED_OPCODES,
1003        };
1004        use crate::webizen::SlgOpcode;
1005
1006        // Parse via the heap front-end (the compiler consumes `Rule`).
1007        let mut parser = N3Parser::new("{ ?x a ?y } => { ?y a ?z } .");
1008        let mut compiled_ok = false;
1009        parser
1010            .parse_all(|ev| {
1011                if let N3Event::LogicRule(rule) = ev {
1012                    let compiled = compile_rule_to_zero_heap(&rule);
1013                    let mut ops = [SlgOpcode::Call; MAX_COMPILED_OPCODES];
1014                    let n = compile_rule_to_opcodes(&compiled, &mut ops).unwrap();
1015                    assert!(n >= 3);
1016                    assert_eq!(ops[0], SlgOpcode::Unify);
1017                    compiled_ok = true;
1018                }
1019                Ok(())
1020            })
1021            .unwrap();
1022        assert!(
1023            compiled_ok,
1024            "the implication rule did not reach the compiler"
1025        );
1026    }
1027}