Skip to main content

trilogy_parser/
trilogy_parser.rs

1use pest_derive::Parser;
2
3#[derive(Parser)]
4#[grammar = "trilogy.pest"]
5pub struct TrilogyParser;
6
7/// How many rule calls a parse of `text` makes, or `None` when it makes more
8/// than `max_calls`. Parse errors other than the call limit are returned.
9///
10/// The count is the gate on parse cost: it is deterministic and the same on
11/// every machine, where a timing is neither. pest exposes it only as a limit
12/// (`set_call_limit`, a process-wide setting), so the count is the smallest
13/// limit the parse completes under, found by bisection; every probe stops at
14/// its limit, so a pathological input costs at most `max_calls` calls per
15/// probe rather than running to completion. Probes are serialized because
16/// the limit is global, and it is cleared before this returns.
17pub fn rule_calls(text: &str, max_calls: usize) -> Result<Option<usize>, Box<pest::error::Error<Rule>>> {
18    use pest::error::ErrorVariant;
19    use pest::Parser;
20    use std::num::NonZeroUsize;
21    use std::sync::Mutex;
22
23    static PROBE: Mutex<()> = Mutex::new(());
24    let _probe = PROBE.lock().unwrap_or_else(|e| e.into_inner());
25
26    let completes_within = |limit: usize| -> Result<bool, Box<pest::error::Error<Rule>>> {
27        pest::set_call_limit(NonZeroUsize::new(limit));
28        let result = TrilogyParser::parse(Rule::start, text);
29        pest::set_call_limit(None);
30        match result {
31            Ok(_) => Ok(true),
32            Err(e) => match &e.variant {
33                ErrorVariant::CustomError { message } if message == "call limit reached" => Ok(false),
34                _ => Err(Box::new(e)),
35            },
36        }
37    };
38    if !completes_within(max_calls)? {
39        return Ok(None);
40    }
41    let (mut low, mut high) = (1usize, max_calls);
42    while low < high {
43        let mid = low + (high - low) / 2;
44        if completes_within(mid)? {
45            high = mid;
46        } else {
47            low = mid + 1;
48        }
49    }
50    Ok(Some(low))
51}
52
53// Everything below is the PyO3 conversion layer over the pest tree and only
54// exists in `python`-feature builds. `TrilogyParser`/`Rule` above stay
55// available to pure-Rust consumers (the CLI, downstream crates).
56#[cfg(feature = "python")]
57mod python_impl {
58use super::{Rule, TrilogyParser};
59use pest::iterators::Pair;
60use pest::Parser;
61use pyo3::prelude::*;
62use pyo3::types::{PyList, PyString, PyTuple};
63use std::collections::HashMap;
64use std::sync::Mutex;
65
66fn rename_rule(name: &str) -> &str {
67    match name {
68        "attr_access_paren" => "attr_access",
69        "_and_conditional" => "conditional",
70        other => other,
71    }
72}
73
74fn is_token_rule(name: &str) -> bool {
75    name.chars()
76        .find(|c| c.is_alphabetic())
77        .map(|c| c.is_ascii_uppercase())
78        .unwrap_or(false)
79}
80
81// Atomic rules whose name starts with `_` are silent-atomic helpers used only
82// for matching (e.g. multi-word keywords like `def table`). They should not
83// appear in the v2 syntax tree.
84fn is_silent_token_rule(name: &str) -> bool {
85    name.starts_with('_')
86}
87
88struct RuleInfo {
89    name_py: Py<PyString>,
90    is_token: bool,
91    is_silent: bool,
92}
93
94// Per-Rule cache of (interned Py<PyString> name, is_token flag, is_silent flag).
95// Populated lazily — amortizes format! + rename_rule to once per unique rule.
96static RULE_INFO_CACHE: Mutex<Option<HashMap<Rule, RuleInfo>>> = Mutex::new(None);
97
98fn rule_info(py: Python<'_>, rule: Rule) -> (Py<PyString>, bool, bool) {
99    let mut borrowed = RULE_INFO_CACHE.lock().unwrap();
100    let map = borrowed.get_or_insert_with(HashMap::new);
101    if let Some(info) = map.get(&rule) {
102        return (info.name_py.clone_ref(py), info.is_token, info.is_silent);
103    }
104    let raw = format!("{:?}", rule);
105    let renamed = rename_rule(&raw);
106    let is_token = is_token_rule(&raw);
107    let is_silent = is_token && is_silent_token_rule(&raw);
108    let name_py = PyString::intern(py, renamed).unbind();
109    let cloned = name_py.clone_ref(py);
110    map.insert(
111        rule,
112        RuleInfo {
113            name_py,
114            is_token,
115            is_silent,
116        },
117    );
118    (cloned, is_token, is_silent)
119}
120
121// Interns a short `&'static str` as a cached Py<PyString>. Used for access_chain
122// rewrites that synthesize names not present in the grammar.
123static STATIC_STR_CACHE: Mutex<Option<HashMap<&'static str, Py<PyString>>>> = Mutex::new(None);
124
125fn intern_static(py: Python<'_>, s: &'static str) -> Py<PyString> {
126    let mut borrowed = STATIC_STR_CACHE.lock().unwrap();
127    let map = borrowed.get_or_insert_with(HashMap::new);
128    if let Some(existing) = map.get(s) {
129        return existing.clone_ref(py);
130    }
131    let py_str = PyString::intern(py, s).unbind();
132    map.insert(s, py_str.clone_ref(py));
133    py_str
134}
135
136#[pyclass(module = "_preql_import_resolver")]
137pub struct PestNode {
138    data_py: Py<PyString>,
139    children_list: Py<PyList>,
140    #[pyo3(get)]
141    pub line: usize,
142    #[pyo3(get)]
143    pub column: usize,
144    #[pyo3(get)]
145    pub end_line: usize,
146    #[pyo3(get)]
147    pub end_column: usize,
148    #[pyo3(get)]
149    pub start_pos: usize,
150    #[pyo3(get)]
151    pub end_pos: usize,
152}
153
154#[pymethods]
155impl PestNode {
156    #[getter]
157    fn data(&self, py: Python<'_>) -> Py<PyString> {
158        self.data_py.clone_ref(py)
159    }
160
161    #[getter]
162    fn children<'py>(&self, py: Python<'py>) -> Bound<'py, PyList> {
163        self.children_list.bind(py).clone()
164    }
165
166    // `meta` returns self so that `SyntaxMeta.from_parser_meta` reads
167    // line/column/etc. directly off the node — one fewer Python allocation per node.
168    #[getter]
169    fn meta(slf: Py<Self>) -> Py<Self> {
170        slf
171    }
172
173    fn __repr__(&self, py: Python<'_>) -> String {
174        format!("PestNode({})", self.data_py.bind(py).to_string_lossy())
175    }
176}
177
178#[pyclass(module = "_preql_import_resolver")]
179pub struct PestToken {
180    type_name_py: Py<PyString>,
181    value_py: Py<PyString>,
182    #[pyo3(get)]
183    pub line: usize,
184    #[pyo3(get)]
185    pub column: usize,
186    #[pyo3(get)]
187    pub end_line: usize,
188    #[pyo3(get)]
189    pub end_column: usize,
190    #[pyo3(get)]
191    pub start_pos: usize,
192    #[pyo3(get)]
193    pub end_pos: usize,
194}
195
196#[pymethods]
197impl PestToken {
198    #[getter]
199    #[pyo3(name = "type")]
200    fn type_name(&self, py: Python<'_>) -> Py<PyString> {
201        self.type_name_py.clone_ref(py)
202    }
203
204    #[getter]
205    fn value(&self, py: Python<'_>) -> Py<PyString> {
206        self.value_py.clone_ref(py)
207    }
208
209    fn __repr__(&self, py: Python<'_>) -> String {
210        format!(
211            "PestToken({}={:?})",
212            self.type_name_py.bind(py).to_string_lossy(),
213            self.value_py.bind(py).to_string_lossy()
214        )
215    }
216
217    fn __str__(&self, py: Python<'_>) -> String {
218        self.value_py.bind(py).to_string_lossy().into_owned()
219    }
220}
221
222// Precomputed mapping from pest byte offsets to Python char offsets, plus
223// line-start char positions for O(log n) line/col lookup. Pest gives us byte
224// offsets in the UTF-8 source, but Python str indexing is char-based — for
225// multibyte source (non-ASCII identifiers, strings) the two diverge and the
226// downstream hydrator gets nonsensical positions.
227struct LineIndex {
228    // Byte offset of each Unicode scalar value, plus a final sentinel == text.len().
229    // Sorted ascending; binary_search converts byte → char index.
230    char_byte_starts: Vec<usize>,
231    // Char positions of each line start (0, then char-after each '\n').
232    line_starts_char: Vec<usize>,
233}
234
235impl LineIndex {
236    fn new(text: &str) -> Self {
237        let mut char_byte_starts = Vec::with_capacity(text.len() + 1);
238        let mut line_starts_char = Vec::with_capacity(text.len() / 40 + 1);
239        line_starts_char.push(0);
240        let mut char_idx = 0usize;
241        for (byte_offset, ch) in text.char_indices() {
242            char_byte_starts.push(byte_offset);
243            if ch == '\n' {
244                line_starts_char.push(char_idx + 1);
245            }
246            char_idx += 1;
247        }
248        char_byte_starts.push(text.len());
249        Self {
250            char_byte_starts,
251            line_starts_char,
252        }
253    }
254
255    fn byte_to_char(&self, byte_pos: usize) -> usize {
256        match self.char_byte_starts.binary_search(&byte_pos) {
257            Ok(i) => i,
258            Err(i) => i - 1,
259        }
260    }
261
262    // Returns (line, col, char_pos) — line/col are 1-based, char_pos is 0-based.
263    fn line_col(&self, byte_pos: usize) -> (usize, usize, usize) {
264        let char_pos = self.byte_to_char(byte_pos);
265        let idx = match self.line_starts_char.binary_search(&char_pos) {
266            Ok(i) => i,
267            Err(i) => i - 1,
268        };
269        let line_start = self.line_starts_char[idx];
270        (idx + 1, char_pos - line_start + 1, char_pos)
271    }
272}
273
274type SpanTuple = (usize, usize, usize, usize, usize, usize);
275
276fn span_positions(pair: &Pair<Rule>, idx: &LineIndex) -> SpanTuple {
277    let span = pair.as_span();
278    span_from_to(span.start(), span.end(), idx)
279}
280
281// The two output representations (PestNode/PestToken pyclasses, and the
282// tuple layout `parse_trilogy_syntax_tuple` returns) share one walk over the
283// pest tree, so every shape rewrite below is written once.
284trait TreeBuilder {
285    fn node(&self, py: Python<'_>, name: Py<PyString>, children: Vec<Py<PyAny>>, span: SpanTuple) -> PyResult<Py<PyAny>>;
286    fn token(&self, py: Python<'_>, name: Py<PyString>, value: &str, span: SpanTuple) -> PyResult<Py<PyAny>>;
287}
288
289struct ClassBuilder;
290
291impl TreeBuilder for ClassBuilder {
292    fn node(&self, py: Python<'_>, name: Py<PyString>, children: Vec<Py<PyAny>>, span: SpanTuple) -> PyResult<Py<PyAny>> {
293        let (line, column, end_line, end_column, start_pos, end_pos) = span;
294        let node = PestNode {
295            data_py: name,
296            children_list: PyList::new(py, children)?.unbind(),
297            line,
298            column,
299            end_line,
300            end_column,
301            start_pos,
302            end_pos,
303        };
304        Ok(Py::new(py, node)?.into_any())
305    }
306
307    fn token(&self, py: Python<'_>, name: Py<PyString>, value: &str, span: SpanTuple) -> PyResult<Py<PyAny>> {
308        let (line, column, end_line, end_column, start_pos, end_pos) = span;
309        let token = PestToken {
310            type_name_py: name,
311            value_py: PyString::new(py, value).unbind(),
312            line,
313            column,
314            end_line,
315            end_column,
316            start_pos,
317            end_pos,
318        };
319        Ok(Py::new(py, token)?.into_any())
320    }
321}
322
323// Tuple-based representation: each node/token becomes a PyTuple, avoiding
324// the pyclass allocation overhead of PestNode/PestToken. Layout:
325//   node:  (name_py, children_py_tuple, line, col, end_line, end_col, start_pos, end_pos)
326//   token: (name_py, value_py_string,   line, col, end_line, end_col, start_pos, end_pos)
327// Nodes vs tokens are distinguished by whether element[1] is a tuple or str.
328struct TupleBuilder;
329
330impl TreeBuilder for TupleBuilder {
331    fn node(&self, py: Python<'_>, name: Py<PyString>, children: Vec<Py<PyAny>>, span: SpanTuple) -> PyResult<Py<PyAny>> {
332        let (line, column, end_line, end_column, start_pos, end_pos) = span;
333        let children = PyTuple::new(py, children)?;
334        Ok((name, children, line, column, end_line, end_column, start_pos, end_pos)
335            .into_pyobject(py)?
336            .into_any()
337            .unbind())
338    }
339
340    fn token(&self, py: Python<'_>, name: Py<PyString>, value: &str, span: SpanTuple) -> PyResult<Py<PyAny>> {
341        let (line, column, end_line, end_column, start_pos, end_pos) = span;
342        let value_py = PyString::new(py, value).unbind();
343        Ok((name, value_py, line, column, end_line, end_column, start_pos, end_pos)
344            .into_pyobject(py)?
345            .into_any()
346            .unbind())
347    }
348}
349
350fn span_from_to(start_byte: usize, end_byte: usize, idx: &LineIndex) -> SpanTuple {
351    let (start_line, start_col, start_char) = idx.line_col(start_byte);
352    let (end_line, end_col, end_char) = idx.line_col(end_byte);
353    (start_line, start_col, end_line, end_col, start_char, end_char)
354}
355
356// The pairs the adapter sees: pest drops silent rules, and the silent-atomic
357// helpers (`_DEF_TABLE`) and EOI are dropped here.
358fn visible_children(pair: Pair<Rule>) -> Vec<Pair<Rule>> {
359    pair.into_inner()
360        .filter(|inner| {
361            let name = format!("{:?}", inner.as_rule());
362            !matches!(inner.as_rule(), Rule::EOI) && !(is_token_rule(&name) && is_silent_token_rule(&name))
363        })
364        .collect()
365}
366
367fn convert_children<B: TreeBuilder>(py: Python<'_>, pairs: Vec<Pair<Rule>>, idx: &LineIndex, b: &B) -> PyResult<Vec<Py<PyAny>>> {
368    pairs.into_iter().map(|p| convert_pair(py, p, idx, b)).collect()
369}
370
371// paren_group = "(" ~ expr ~ ("," ~ expr)* ~ ","? ~ ")"
372// A single expr with no comma is lark's `parenthetical`; anything else its
373// `expr_tuple`. The one-tuple `(x,)` is told from `(x)` by the comma in the
374// gap between the expr and `)`, which may also hold whitespace and comments.
375fn paren_group_is_tuple(pair: &Pair<Rule>) -> bool {
376    let exprs: Vec<Pair<Rule>> = pair.clone().into_inner().collect();
377    if exprs.len() != 1 {
378        return true;
379    }
380    let text = pair.as_str();
381    let gap = &text[exprs[0].as_span().end() - pair.as_span().start()..];
382    let bytes = gap.as_bytes();
383    let mut i = 0;
384    while i < bytes.len() {
385        match bytes[i] {
386            b',' => return true,
387            b'#' => while i < bytes.len() && bytes[i] != b'\n' { i += 1 },
388            b'/' if bytes.get(i + 1) == Some(&b'/') => while i < bytes.len() && bytes[i] != b'\n' { i += 1 },
389            b'/' if bytes.get(i + 1) == Some(&b'*') => {
390                i += 2;
391                while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { i += 1 }
392                i += 1;
393            }
394            _ => {}
395        }
396        i += 1;
397    }
398    false
399}
400
401fn convert_paren_group<B: TreeBuilder>(py: Python<'_>, pair: Pair<Rule>, idx: &LineIndex, b: &B) -> PyResult<Py<PyAny>> {
402    let span = span_positions(&pair, idx);
403    let name = if paren_group_is_tuple(&pair) { "expr_tuple" } else { "parenthetical" };
404    let items = convert_children(py, visible_children(pair), idx, b)?;
405    b.node(py, intern_static(py, name), items, span)
406}
407
408// access_chain = (_unfilterable_atom ~ _access_tail*) | (_atom ~ (("?" ~ conditional) | _access_tail*))
409// Left-factored to parse the atom once. Here we rewrite the node name and shape
410// to match what the v2 hydrator (and lark grammar) expects.
411fn convert_access_chain<B: TreeBuilder>(py: Python<'_>, pair: Pair<Rule>, idx: &LineIndex, b: &B) -> PyResult<Py<PyAny>> {
412    let span = span_positions(&pair, idx);
413    let mut inner = pair.into_inner();
414    let atom_pair = inner
415        .next()
416        .expect("access_chain must contain an atom as first child");
417
418    // `X ? cond` is lark's `_filter_alt`: a filter_item over the bare head,
419    // so a parenthesized head contributes its expr and a concept reference
420    // its IDENTIFIER token.
421    let mut inner = inner.peekable();
422    if matches!(inner.peek().map(|p| p.as_rule()), Some(Rule::conditional)) {
423        let conditional = inner.next().unwrap();
424        let bare = match atom_pair.as_rule() {
425            Rule::concept_lit => true,
426            Rule::paren_group => !paren_group_is_tuple(&atom_pair),
427            _ => false,
428        };
429        let head_pair = if bare {
430            atom_pair
431                .into_inner()
432                .next()
433                .expect("a parenthetical and a concept_lit each hold one child")
434        } else {
435            atom_pair
436        };
437        let head = convert_pair(py, head_pair, idx, b)?;
438        let cond = convert_pair(py, conditional, idx, b)?;
439        return b.node(py, intern_static(py, "filter_item"), vec![head, cond], span);
440    }
441
442    let mut tails: Vec<(Rule, Pair<Rule>)> = Vec::new();
443    for tail in inner {
444        let kind = tail.as_rule();
445        let payload = tail
446            .into_inner()
447            .next()
448            .expect("tail rule must have one payload child");
449        tails.push((kind, payload));
450    }
451
452    if tails.is_empty() {
453        return convert_pair(py, atom_pair, idx, b);
454    }
455
456    let atom_py = convert_pair(py, atom_pair, idx, b)?;
457
458    let tail_name = |kind: Rule, payload: &Pair<Rule>| -> &'static str {
459        match kind {
460            Rule::dot_tail => "attr_access",
461            Rule::bracket_tail => match payload.as_rule() {
462                Rule::int_lit => "index_access",
463                _ => "map_key_access",
464            },
465            Rule::dcolon_tail => "fcast",
466            _ => "chained_access",
467        }
468    };
469
470    let last_is_cast = matches!(tails.last().map(|(k, _)| *k), Some(Rule::dcolon_tail));
471
472    if tails.len() == 1 {
473        let (kind, payload) = tails.pop().unwrap();
474        let name = tail_name(kind, &payload);
475        let payload_py = convert_pair(py, payload, idx, b)?;
476        return b.node(py, intern_static(py, name), vec![atom_py, payload_py], span);
477    }
478
479    if last_is_cast {
480        let (_, cast_payload) = tails.pop().unwrap();
481        let inner_name: &'static str = if tails.len() == 1 {
482            let (k, p) = &tails[0];
483            tail_name(*k, p)
484        } else {
485            "chained_access"
486        };
487        let mut inner_items: Vec<Py<PyAny>> = Vec::with_capacity(tails.len() + 1);
488        inner_items.push(atom_py);
489        for (_, payload) in tails.into_iter() {
490            inner_items.push(convert_pair(py, payload, idx, b)?);
491        }
492        let inner_node = b.node(py, intern_static(py, inner_name), inner_items, span)?;
493        let cast_py = convert_pair(py, cast_payload, idx, b)?;
494        return b.node(py, intern_static(py, "fcast"), vec![inner_node, cast_py], span);
495    }
496
497    let mut items: Vec<Py<PyAny>> = Vec::with_capacity(tails.len() + 1);
498    items.push(atom_py);
499    for (_, payload) in tails.into_iter() {
500        items.push(convert_pair(py, payload, idx, b)?);
501    }
502    b.node(py, intern_static(py, "chained_access"), items, span)
503}
504
505// expr = sum_operator ~ (_between_tail | _comparison_tail)?
506// Left-factored to parse the head once; the children say which lark rule it
507// is: [head] or [head, COMPARISON_OPERATOR, rhs] is a `comparison`,
508// [head, low, high] a `between_comparison`.
509fn convert_expr<B: TreeBuilder>(py: Python<'_>, pair: Pair<Rule>, idx: &LineIndex, b: &B) -> PyResult<Py<PyAny>> {
510    let span = span_positions(&pair, idx);
511    let children = visible_children(pair);
512    let is_between = children.len() == 3 && matches!(children[1].as_rule(), Rule::sum_operator);
513    let name = if is_between { "between_comparison" } else { "comparison" };
514    let items = convert_children(py, children, idx, b)?;
515    b.node(py, intern_static(py, name), items, span)
516}
517
518// select_chain = select_statement ~ _multi_select_tail?
519// A chain with no tail is its select_statement; one with a `merge` tail is
520// the multi_select_statement whose children follow.
521fn convert_select_chain<B: TreeBuilder>(py: Python<'_>, pair: Pair<Rule>, idx: &LineIndex, b: &B) -> PyResult<Py<PyAny>> {
522    let span = span_positions(&pair, idx);
523    let mut children = visible_children(pair);
524    if children.len() == 1 {
525        return convert_pair(py, children.pop().unwrap(), idx, b);
526    }
527    let items = convert_children(py, children, idx, b)?;
528    b.node(py, intern_static(py, "multi_select_statement"), items, span)
529}
530
531// aggregate_functions = _call ~ aggregate_over? ~ window_sql_over?
532// With the `over (...)` tail the call is lark's aggregate-as-window:
533// window_item(window_item_sql_aggregate(aggregate_functions, window_sql_over)).
534// The inner aggregate_functions keeps the span the rule had before the tail
535// joined it (`_call ~ aggregate_over?`): through its `by` clause when it has
536// one, else through the whitespace pest skipped before declining that clause.
537fn convert_aggregate_functions<B: TreeBuilder>(py: Python<'_>, pair: Pair<Rule>, idx: &LineIndex, b: &B) -> PyResult<Py<PyAny>> {
538    let (start_byte, end_byte) = (pair.as_span().start(), pair.as_span().end());
539    let (name_py, _, _) = rule_info(py, pair.as_rule());
540    let mut children = visible_children(pair);
541    let over_pair = match children.last().map(|c| c.as_rule()) {
542        Some(Rule::window_sql_over) => children.pop(),
543        _ => None,
544    };
545    let call_end = match (children.last(), &over_pair) {
546        (Some(last), _) if matches!(last.as_rule(), Rule::aggregate_over) => last.as_span().end(),
547        (_, Some(over)) => over.as_span().start(),
548        _ => end_byte,
549    };
550    let call_items = convert_children(py, children, idx, b)?;
551    let call = b.node(py, name_py, call_items, span_from_to(start_byte, call_end, idx))?;
552    let Some(over_pair) = over_pair else {
553        return Ok(call);
554    };
555    let span = span_from_to(start_byte, end_byte, idx);
556    let over = convert_pair(py, over_pair, idx, b)?;
557    let windowed = b.node(py, intern_static(py, "window_item_sql_aggregate"), vec![call, over], span)?;
558    b.node(py, intern_static(py, "window_item"), vec![windowed], span)
559}
560
561fn convert_pair<B: TreeBuilder>(py: Python<'_>, pair: Pair<Rule>, idx: &LineIndex, b: &B) -> PyResult<Py<PyAny>> {
562    match pair.as_rule() {
563        Rule::access_chain => return convert_access_chain(py, pair, idx, b),
564        Rule::expr => return convert_expr(py, pair, idx, b),
565        Rule::select_chain => return convert_select_chain(py, pair, idx, b),
566        Rule::aggregate_functions => return convert_aggregate_functions(py, pair, idx, b),
567        Rule::paren_group => return convert_paren_group(py, pair, idx, b),
568        _ => {}
569    }
570
571    let (name_py, is_token, _is_silent) = rule_info(py, pair.as_rule());
572    let span = span_positions(&pair, idx);
573
574    if is_token {
575        return b.token(py, name_py, pair.as_str(), span);
576    }
577    let mut items: Vec<Py<PyAny>> = Vec::new();
578    for inner in pair.into_inner() {
579        if matches!(inner.as_rule(), Rule::EOI) {
580            continue;
581        }
582        let (_, _, inner_silent) = rule_info(py, inner.as_rule());
583        if inner_silent {
584            continue;
585        }
586        items.push(convert_pair(py, inner, idx, b)?);
587    }
588    b.node(py, name_py, items, span)
589}
590
591fn parse_root<'i>(text: &'i str) -> PyResult<Pair<'i, Rule>> {
592    let mut pairs = TrilogyParser::parse(Rule::start, text).map_err(|e| {
593        PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("{}", e))
594    })?;
595    pairs
596        .next()
597        .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("empty parse result"))
598}
599
600/// Parse a Trilogy source text and return a duck-typed syntax tree that
601/// `trilogy.parsing.v2.syntax.syntax_from_parser` can consume unchanged.
602#[pyfunction]
603pub fn parse_trilogy_syntax(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
604    let idx = LineIndex::new(text);
605    let start = parse_root(text)?;
606    convert_pair(py, start, &idx, &ClassBuilder)
607}
608
609/// Tuple-based parse: returns a nested PyTuple tree. Layout at `TupleBuilder`.
610#[pyfunction]
611pub fn parse_trilogy_syntax_tuple(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
612    let idx = LineIndex::new(text);
613    let start = parse_root(text)?;
614    convert_pair(py, start, &idx, &TupleBuilder)
615}
616
617/// The number of pest rule calls a parse of `text` makes (see `rule_calls`),
618/// the deterministic measure the parse-cost tests gate on. Raises when the
619/// parse needs more than `max_calls` calls or does not parse at all.
620#[pyfunction]
621pub fn parse_trilogy_syntax_calls(text: &str, max_calls: usize) -> PyResult<usize> {
622    match super::rule_calls(text, max_calls) {
623        Ok(Some(calls)) => Ok(calls),
624        Ok(None) => Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
625            "parse needs more than {max_calls} rule calls"
626        ))),
627        Err(e) => Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("{}", e))),
628    }
629}
630
631/// Parse-only benchmark helper: runs the pest parser and walks the pair tree
632/// to count nodes, but does NOT construct any Python objects. Useful to
633/// separate raw pest cost from PyO3 conversion overhead.
634#[pyfunction]
635pub fn parse_trilogy_syntax_count(text: &str) -> PyResult<usize> {
636    let mut pairs = TrilogyParser::parse(Rule::start, text).map_err(|e| {
637        PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("{}", e))
638    })?;
639    let start = pairs
640        .next()
641        .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("empty parse result"))?;
642    fn walk(pair: Pair<Rule>) -> usize {
643        let mut n = 1;
644        for inner in pair.into_inner() {
645            n += walk(inner);
646        }
647        n
648    }
649    Ok(walk(start))
650}
651}
652
653#[cfg(feature = "python")]
654pub use python_impl::{
655    parse_trilogy_syntax, parse_trilogy_syntax_calls, parse_trilogy_syntax_count,
656    parse_trilogy_syntax_tuple, PestNode, PestToken,
657};