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// Everything below is the PyO3 conversion layer over the pest tree and only
8// exists in `python`-feature builds. `TrilogyParser`/`Rule` above stay
9// available to pure-Rust consumers (the CLI, downstream crates).
10#[cfg(feature = "python")]
11mod python_impl {
12use super::{Rule, TrilogyParser};
13use pest::iterators::Pair;
14use pest::Parser;
15use pyo3::prelude::*;
16use pyo3::types::{PyList, PyString, PyTuple};
17use std::collections::HashMap;
18use std::sync::Mutex;
19
20fn rename_rule(name: &str) -> &str {
21    match name {
22        "attr_access_paren" => "attr_access",
23        "_and_conditional" => "conditional",
24        other => other,
25    }
26}
27
28fn is_token_rule(name: &str) -> bool {
29    name.chars()
30        .find(|c| c.is_alphabetic())
31        .map(|c| c.is_ascii_uppercase())
32        .unwrap_or(false)
33}
34
35// Atomic rules whose name starts with `_` are silent-atomic helpers used only
36// for matching (e.g. multi-word keywords like `def table`). They should not
37// appear in the v2 syntax tree.
38fn is_silent_token_rule(name: &str) -> bool {
39    name.starts_with('_')
40}
41
42struct RuleInfo {
43    name_py: Py<PyString>,
44    is_token: bool,
45    is_silent: bool,
46}
47
48// Per-Rule cache of (interned Py<PyString> name, is_token flag, is_silent flag).
49// Populated lazily — amortizes format! + rename_rule to once per unique rule.
50static RULE_INFO_CACHE: Mutex<Option<HashMap<Rule, RuleInfo>>> = Mutex::new(None);
51
52fn rule_info(py: Python<'_>, rule: Rule) -> (Py<PyString>, bool, bool) {
53    let mut borrowed = RULE_INFO_CACHE.lock().unwrap();
54    let map = borrowed.get_or_insert_with(HashMap::new);
55    if let Some(info) = map.get(&rule) {
56        return (info.name_py.clone_ref(py), info.is_token, info.is_silent);
57    }
58    let raw = format!("{:?}", rule);
59    let renamed = rename_rule(&raw);
60    let is_token = is_token_rule(&raw);
61    let is_silent = is_token && is_silent_token_rule(&raw);
62    let name_py = PyString::intern(py, renamed).unbind();
63    let cloned = name_py.clone_ref(py);
64    map.insert(
65        rule,
66        RuleInfo {
67            name_py,
68            is_token,
69            is_silent,
70        },
71    );
72    (cloned, is_token, is_silent)
73}
74
75// Interns a short `&'static str` as a cached Py<PyString>. Used for access_chain
76// rewrites that synthesize names not present in the grammar.
77static STATIC_STR_CACHE: Mutex<Option<HashMap<&'static str, Py<PyString>>>> = Mutex::new(None);
78
79fn intern_static(py: Python<'_>, s: &'static str) -> Py<PyString> {
80    let mut borrowed = STATIC_STR_CACHE.lock().unwrap();
81    let map = borrowed.get_or_insert_with(HashMap::new);
82    if let Some(existing) = map.get(s) {
83        return existing.clone_ref(py);
84    }
85    let py_str = PyString::intern(py, s).unbind();
86    map.insert(s, py_str.clone_ref(py));
87    py_str
88}
89
90#[pyclass(module = "_preql_import_resolver")]
91pub struct PestNode {
92    data_py: Py<PyString>,
93    children_list: Py<PyList>,
94    #[pyo3(get)]
95    pub line: usize,
96    #[pyo3(get)]
97    pub column: usize,
98    #[pyo3(get)]
99    pub end_line: usize,
100    #[pyo3(get)]
101    pub end_column: usize,
102    #[pyo3(get)]
103    pub start_pos: usize,
104    #[pyo3(get)]
105    pub end_pos: usize,
106}
107
108#[pymethods]
109impl PestNode {
110    #[getter]
111    fn data(&self, py: Python<'_>) -> Py<PyString> {
112        self.data_py.clone_ref(py)
113    }
114
115    #[getter]
116    fn children<'py>(&self, py: Python<'py>) -> Bound<'py, PyList> {
117        self.children_list.bind(py).clone()
118    }
119
120    // `meta` returns self so that `SyntaxMeta.from_parser_meta` reads
121    // line/column/etc. directly off the node — one fewer Python allocation per node.
122    #[getter]
123    fn meta(slf: Py<Self>) -> Py<Self> {
124        slf
125    }
126
127    fn __repr__(&self, py: Python<'_>) -> String {
128        format!("PestNode({})", self.data_py.bind(py).to_string_lossy())
129    }
130}
131
132#[pyclass(module = "_preql_import_resolver")]
133pub struct PestToken {
134    type_name_py: Py<PyString>,
135    value_py: Py<PyString>,
136    #[pyo3(get)]
137    pub line: usize,
138    #[pyo3(get)]
139    pub column: usize,
140    #[pyo3(get)]
141    pub end_line: usize,
142    #[pyo3(get)]
143    pub end_column: usize,
144    #[pyo3(get)]
145    pub start_pos: usize,
146    #[pyo3(get)]
147    pub end_pos: usize,
148}
149
150#[pymethods]
151impl PestToken {
152    #[getter]
153    #[pyo3(name = "type")]
154    fn type_name(&self, py: Python<'_>) -> Py<PyString> {
155        self.type_name_py.clone_ref(py)
156    }
157
158    #[getter]
159    fn value(&self, py: Python<'_>) -> Py<PyString> {
160        self.value_py.clone_ref(py)
161    }
162
163    fn __repr__(&self, py: Python<'_>) -> String {
164        format!(
165            "PestToken({}={:?})",
166            self.type_name_py.bind(py).to_string_lossy(),
167            self.value_py.bind(py).to_string_lossy()
168        )
169    }
170
171    fn __str__(&self, py: Python<'_>) -> String {
172        self.value_py.bind(py).to_string_lossy().into_owned()
173    }
174}
175
176// Precomputed mapping from pest byte offsets to Python char offsets, plus
177// line-start char positions for O(log n) line/col lookup. Pest gives us byte
178// offsets in the UTF-8 source, but Python str indexing is char-based — for
179// multibyte source (non-ASCII identifiers, strings) the two diverge and the
180// downstream hydrator gets nonsensical positions.
181struct LineIndex {
182    // Byte offset of each Unicode scalar value, plus a final sentinel == text.len().
183    // Sorted ascending; binary_search converts byte → char index.
184    char_byte_starts: Vec<usize>,
185    // Char positions of each line start (0, then char-after each '\n').
186    line_starts_char: Vec<usize>,
187}
188
189impl LineIndex {
190    fn new(text: &str) -> Self {
191        let mut char_byte_starts = Vec::with_capacity(text.len() + 1);
192        let mut line_starts_char = Vec::with_capacity(text.len() / 40 + 1);
193        line_starts_char.push(0);
194        let mut char_idx = 0usize;
195        for (byte_offset, ch) in text.char_indices() {
196            char_byte_starts.push(byte_offset);
197            if ch == '\n' {
198                line_starts_char.push(char_idx + 1);
199            }
200            char_idx += 1;
201        }
202        char_byte_starts.push(text.len());
203        Self {
204            char_byte_starts,
205            line_starts_char,
206        }
207    }
208
209    fn byte_to_char(&self, byte_pos: usize) -> usize {
210        match self.char_byte_starts.binary_search(&byte_pos) {
211            Ok(i) => i,
212            Err(i) => i - 1,
213        }
214    }
215
216    // Returns (line, col, char_pos) — line/col are 1-based, char_pos is 0-based.
217    fn line_col(&self, byte_pos: usize) -> (usize, usize, usize) {
218        let char_pos = self.byte_to_char(byte_pos);
219        let idx = match self.line_starts_char.binary_search(&char_pos) {
220            Ok(i) => i,
221            Err(i) => i - 1,
222        };
223        let line_start = self.line_starts_char[idx];
224        (idx + 1, char_pos - line_start + 1, char_pos)
225    }
226}
227
228fn span_positions(pair: &Pair<Rule>, idx: &LineIndex) -> (usize, usize, usize, usize, usize, usize) {
229    let span = pair.as_span();
230    let (start_line, start_col, start_char) = idx.line_col(span.start());
231    let (end_line, end_col, end_char) = idx.line_col(span.end());
232    (start_line, start_col, end_line, end_col, start_char, end_char)
233}
234
235type SpanTuple = (usize, usize, usize, usize, usize, usize);
236
237fn make_node(
238    py: Python<'_>,
239    name_py: Py<PyString>,
240    list: Bound<PyList>,
241    span: SpanTuple,
242) -> PyResult<Py<PyAny>> {
243    let (line, column, end_line, end_column, start_pos, end_pos) = span;
244    let node = PestNode {
245        data_py: name_py,
246        children_list: list.unbind(),
247        line,
248        column,
249        end_line,
250        end_column,
251        start_pos,
252        end_pos,
253    };
254    Ok(Py::new(py, node)?.into_any())
255}
256
257// access_chain = _atom + (dot_tail | bracket_tail | dcolon_tail)*
258// Left-factored to parse _atom once. Here we rewrite the node name and shape
259// to match what the v2 hydrator (and lark grammar) expects.
260fn convert_access_chain(py: Python<'_>, pair: Pair<Rule>, idx: &LineIndex) -> PyResult<Py<PyAny>> {
261    let span = span_positions(&pair, idx);
262    let mut inner = pair.into_inner();
263    let atom_pair = inner
264        .next()
265        .expect("access_chain must contain an atom as first child");
266
267    let mut tails: Vec<(Rule, Pair<Rule>)> = Vec::new();
268    for tail in inner {
269        let kind = tail.as_rule();
270        let payload = tail
271            .into_inner()
272            .next()
273            .expect("tail rule must have one payload child");
274        tails.push((kind, payload));
275    }
276
277    if tails.is_empty() {
278        return convert_pair(py, atom_pair, idx);
279    }
280
281    let atom_py = convert_pair(py, atom_pair, idx)?;
282
283    let tail_name = |kind: Rule, payload: &Pair<Rule>| -> &'static str {
284        match kind {
285            Rule::dot_tail => "attr_access",
286            Rule::bracket_tail => match payload.as_rule() {
287                Rule::int_lit => "index_access",
288                _ => "map_key_access",
289            },
290            Rule::dcolon_tail => "fcast",
291            _ => "chained_access",
292        }
293    };
294
295    let last_is_cast = matches!(tails.last().map(|(k, _)| *k), Some(Rule::dcolon_tail));
296
297    if tails.len() == 1 {
298        let (kind, payload) = tails.pop().unwrap();
299        let name = tail_name(kind, &payload);
300        let list = PyList::empty(py);
301        list.append(atom_py)?;
302        list.append(convert_pair(py, payload, idx)?)?;
303        return make_node(py, intern_static(py, name), list, span);
304    }
305
306    if last_is_cast {
307        let (_, cast_payload) = tails.pop().unwrap();
308        let inner_name: &'static str = if tails.len() == 1 {
309            let (k, p) = &tails[0];
310            tail_name(*k, p)
311        } else {
312            "chained_access"
313        };
314        let inner_list = PyList::empty(py);
315        inner_list.append(atom_py)?;
316        for (_, payload) in tails.into_iter() {
317            inner_list.append(convert_pair(py, payload, idx)?)?;
318        }
319        let inner_node = make_node(py, intern_static(py, inner_name), inner_list, span)?;
320
321        let outer_list = PyList::empty(py);
322        outer_list.append(inner_node)?;
323        outer_list.append(convert_pair(py, cast_payload, idx)?)?;
324        return make_node(py, intern_static(py, "fcast"), outer_list, span);
325    }
326
327    let list = PyList::empty(py);
328    list.append(atom_py)?;
329    for (_, payload) in tails.into_iter() {
330        list.append(convert_pair(py, payload, idx)?)?;
331    }
332    make_node(py, intern_static(py, "chained_access"), list, span)
333}
334
335fn convert_pair(py: Python<'_>, pair: Pair<Rule>, idx: &LineIndex) -> PyResult<Py<PyAny>> {
336    let rule = pair.as_rule();
337    if matches!(rule, Rule::access_chain) {
338        return convert_access_chain(py, pair, idx);
339    }
340
341    let (name_py, is_token, _is_silent) = rule_info(py, rule);
342    let span = span_positions(&pair, idx);
343    let (line, column, end_line, end_column, start_pos, end_pos) = span;
344
345    if is_token {
346        let value_py = PyString::new(py, pair.as_str()).unbind();
347        let token = PestToken {
348            type_name_py: name_py,
349            value_py,
350            line,
351            column,
352            end_line,
353            end_column,
354            start_pos,
355            end_pos,
356        };
357        Ok(Py::new(py, token)?.into_any())
358    } else {
359        let list = PyList::empty(py);
360        for inner in pair.into_inner() {
361            if matches!(inner.as_rule(), Rule::EOI) {
362                continue;
363            }
364            let (_, _, inner_silent) = rule_info(py, inner.as_rule());
365            if inner_silent {
366                continue;
367            }
368            let child = convert_pair(py, inner, idx)?;
369            list.append(child)?;
370        }
371        make_node(py, name_py, list, span)
372    }
373}
374
375/// Parse a Trilogy source text and return a duck-typed syntax tree that
376/// `trilogy.parsing.v2.syntax.syntax_from_parser` can consume unchanged.
377#[pyfunction]
378pub fn parse_trilogy_syntax(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
379    let idx = LineIndex::new(text);
380    let mut pairs = TrilogyParser::parse(Rule::start, text).map_err(|e| {
381        PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("{}", e))
382    })?;
383    let start = pairs
384        .next()
385        .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("empty parse result"))?;
386    convert_pair(py, start, &idx)
387}
388
389// -- Tuple-based representation: each node/token becomes a PyTuple, avoiding
390// the pyclass allocation overhead of PestNode/PestToken. Layout:
391//   node:  (name_py, children_py_tuple, line, col, end_line, end_col, start_pos, end_pos)
392//   token: (name_py, value_py_string,   line, col, end_line, end_col, start_pos, end_pos)
393// Nodes vs tokens are distinguished by whether element[1] is a tuple or str.
394
395fn convert_access_chain_tuple<'py>(
396    py: Python<'py>,
397    pair: Pair<Rule>,
398    idx: &LineIndex,
399) -> PyResult<Bound<'py, PyTuple>> {
400    let span = span_positions(&pair, idx);
401    let mut inner = pair.into_inner();
402    let atom_pair = inner
403        .next()
404        .expect("access_chain must contain an atom as first child");
405
406    let mut tails: Vec<(Rule, Pair<Rule>)> = Vec::new();
407    for tail in inner {
408        let kind = tail.as_rule();
409        let payload = tail
410            .into_inner()
411            .next()
412            .expect("tail rule must have one payload child");
413        tails.push((kind, payload));
414    }
415
416    if tails.is_empty() {
417        return convert_pair_tuple(py, atom_pair, idx);
418    }
419
420    let atom_tup = convert_pair_tuple(py, atom_pair, idx)?;
421
422    let tail_name = |kind: Rule, payload: &Pair<Rule>| -> &'static str {
423        match kind {
424            Rule::dot_tail => "attr_access",
425            Rule::bracket_tail => match payload.as_rule() {
426                Rule::int_lit => "index_access",
427                _ => "map_key_access",
428            },
429            Rule::dcolon_tail => "fcast",
430            _ => "chained_access",
431        }
432    };
433
434    let last_is_cast = matches!(tails.last().map(|(k, _)| *k), Some(Rule::dcolon_tail));
435
436    if tails.len() == 1 {
437        let (kind, payload) = tails.pop().unwrap();
438        let name = tail_name(kind, &payload);
439        let payload_tup = convert_pair_tuple(py, payload, idx)?;
440        let children = PyTuple::new(py, [atom_tup.into_any(), payload_tup.into_any()])?;
441        return make_node_tuple(py, intern_static(py, name), children, span);
442    }
443
444    if last_is_cast {
445        let (_, cast_payload) = tails.pop().unwrap();
446        let inner_name: &'static str = if tails.len() == 1 {
447            let (k, p) = &tails[0];
448            tail_name(*k, p)
449        } else {
450            "chained_access"
451        };
452        let mut inner_items: Vec<Py<PyAny>> = Vec::with_capacity(tails.len() + 1);
453        inner_items.push(atom_tup.into_any().unbind());
454        for (_, payload) in tails.into_iter() {
455            inner_items.push(convert_pair_tuple(py, payload, idx)?.into_any().unbind());
456        }
457        let inner_children = PyTuple::new(py, inner_items)?;
458        let inner_node = make_node_tuple(py, intern_static(py, inner_name), inner_children, span)?;
459
460        let cast_tup = convert_pair_tuple(py, cast_payload, idx)?;
461        let outer_children = PyTuple::new(py, [inner_node.into_any(), cast_tup.into_any()])?;
462        return make_node_tuple(py, intern_static(py, "fcast"), outer_children, span);
463    }
464
465    let mut items: Vec<Py<PyAny>> = Vec::with_capacity(tails.len() + 1);
466    items.push(atom_tup.into_any().unbind());
467    for (_, payload) in tails.into_iter() {
468        items.push(convert_pair_tuple(py, payload, idx)?.into_any().unbind());
469    }
470    let children = PyTuple::new(py, items)?;
471    make_node_tuple(py, intern_static(py, "chained_access"), children, span)
472}
473
474fn make_node_tuple<'py>(
475    py: Python<'py>,
476    name_py: Py<PyString>,
477    children: Bound<'py, PyTuple>,
478    span: SpanTuple,
479) -> PyResult<Bound<'py, PyTuple>> {
480    let (line, column, end_line, end_column, start_pos, end_pos) = span;
481    (
482        name_py,
483        children,
484        line,
485        column,
486        end_line,
487        end_column,
488        start_pos,
489        end_pos,
490    )
491        .into_pyobject(py)
492}
493
494fn convert_pair_tuple<'py>(
495    py: Python<'py>,
496    pair: Pair<Rule>,
497    idx: &LineIndex,
498) -> PyResult<Bound<'py, PyTuple>> {
499    let rule = pair.as_rule();
500    if matches!(rule, Rule::access_chain) {
501        return convert_access_chain_tuple(py, pair, idx);
502    }
503
504    let (name_py, is_token, _is_silent) = rule_info(py, rule);
505    let span = span_positions(&pair, idx);
506    let (line, column, end_line, end_column, start_pos, end_pos) = span;
507
508    if is_token {
509        let value_py = PyString::new(py, pair.as_str()).unbind();
510        (
511            name_py,
512            value_py,
513            line,
514            column,
515            end_line,
516            end_column,
517            start_pos,
518            end_pos,
519        )
520            .into_pyobject(py)
521    } else {
522        let mut items: Vec<Py<PyAny>> = Vec::new();
523        for inner in pair.into_inner() {
524            if matches!(inner.as_rule(), Rule::EOI) {
525                continue;
526            }
527            let (_, _, inner_silent) = rule_info(py, inner.as_rule());
528            if inner_silent {
529                continue;
530            }
531            items.push(convert_pair_tuple(py, inner, idx)?.into_any().unbind());
532        }
533        let children = PyTuple::new(py, items)?;
534        make_node_tuple(py, name_py, children, span)
535    }
536}
537
538/// Tuple-based parse: returns a nested PyTuple tree. Layout described above.
539#[pyfunction]
540pub fn parse_trilogy_syntax_tuple(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
541    let idx = LineIndex::new(text);
542    let mut pairs = TrilogyParser::parse(Rule::start, text).map_err(|e| {
543        PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("{}", e))
544    })?;
545    let start = pairs
546        .next()
547        .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("empty parse result"))?;
548    Ok(convert_pair_tuple(py, start, &idx)?.into_any().unbind())
549}
550
551/// Parse-only benchmark helper: runs the pest parser and walks the pair tree
552/// to count nodes, but does NOT construct any Python objects. Useful to
553/// separate raw pest cost from PyO3 conversion overhead.
554#[pyfunction]
555pub fn parse_trilogy_syntax_count(text: &str) -> PyResult<usize> {
556    let mut pairs = TrilogyParser::parse(Rule::start, text).map_err(|e| {
557        PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("{}", e))
558    })?;
559    let start = pairs
560        .next()
561        .ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>("empty parse result"))?;
562    fn walk(pair: Pair<Rule>) -> usize {
563        let mut n = 1;
564        for inner in pair.into_inner() {
565            n += walk(inner);
566        }
567        n
568    }
569    Ok(walk(start))
570}
571}
572
573#[cfg(feature = "python")]
574pub use python_impl::{
575    parse_trilogy_syntax, parse_trilogy_syntax_count, parse_trilogy_syntax_tuple, PestNode,
576    PestToken,
577};