Skip to main content

polydat_grammar/
refs.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Grammar-based free-name extraction for Polydat expression text.
5//!
6//! Consumers that need to know "which wire / param / coordinate
7//! names does this expression reference?" — workload validators,
8//! comprehension-source analysis, the YAML→Polydat fusion layer —
9//! MUST go through this module rather than byte-scanning the
10//! source text for `{...}` or identifier-shaped runs. Byte
11//! scanning misclassifies bare wire references (`concat(foo)`),
12//! function names, and string-literal contents; the grammar
13//! resolves all three correctly because it parses the same
14//! tokens the kernel compiler does.
15//!
16//! The extractor is built on the canonical lexer + expression
17//! parser ([`crate::lexer::lex`] +
18//! [`crate::parser::parse_expression`]) so a reference is
19//! recognised exactly when the compiler would treat it as one.
20//! `FieldAccess` (`base.vector`) contributes its `source` name;
21//! `StringLit` contributes the `{name}` interpolation references
22//! it carries; function callee names are NOT references (they
23//! resolve in the function registry, not the wire scope).
24
25use std::collections::BTreeSet;
26
27use crate::ast::{Arg, Expr};
28
29/// Parse `text` as a single Polydat expression and return the
30/// set of free names it references — wire/param/coordinate
31/// identifiers, `FieldAccess` source names, and `{name}`
32/// interpolation references inside string literals. Function
33/// callee names are excluded (they resolve in the function
34/// registry).
35///
36/// Returns an empty set when `text` does not lex/parse as a
37/// single expression. Callers that need to distinguish "no
38/// references" from "not an expression" should use
39/// [`try_referenced_names`].
40pub fn referenced_names(text: &str) -> BTreeSet<String> {
41    try_referenced_names(text).unwrap_or_default()
42}
43
44/// Like [`referenced_names`] but returns `Err` with the
45/// lexer/parser diagnostic when `text` is not a single valid
46/// Polydat expression. Use when a parse failure should surface
47/// to the operator rather than silently yield no references.
48pub fn try_referenced_names(text: &str) -> Result<BTreeSet<String>, String> {
49    let tokens = crate::lexer::lex(text)?;
50    let expr = crate::parser::parse_expression(tokens)?;
51    let mut out = BTreeSet::new();
52    collect_expr_refs(&expr, &mut out);
53    Ok(out)
54}
55
56/// Walk a parsed [`Expr`], inserting every free name reference
57/// into `out`. The traversal mirrors the runtime's
58/// `dsl::validate::validate_expr` (in `polydat-core`)
59/// reference-collection arm minus the diagnostics, so the two stay
60/// in lockstep about what counts as a reference.
61pub fn collect_expr_refs(expr: &Expr, out: &mut BTreeSet<String>) {
62    match expr {
63        Expr::Ident(name, _) => {
64            // The lexer has no BoolLit variant — `true` / `false`
65            // arrive as `Expr::Ident`. They are keyword literals,
66            // not wire references (every typed evaluator special-
67            // cases them), so they must not count as references —
68            // matches the runtime's `dsl::validate::collect_references`
69            // in `polydat-core`.
70            if name != "true" && name != "false" {
71                out.insert(name.clone());
72            }
73        }
74        Expr::Call(call) => {
75            // The callee name is a function-registry lookup, not
76            // a wire reference — skip it. Arguments are walked.
77            for arg in &call.args {
78                let inner = match arg {
79                    Arg::Positional(e) => e,
80                    Arg::Named(_, e) => e,
81                };
82                collect_expr_refs(inner, out);
83            }
84        }
85        Expr::BinOp(lhs, _, rhs) => {
86            collect_expr_refs(lhs, out);
87            collect_expr_refs(rhs, out);
88        }
89        // A producer's element names are bound inside its own scope;
90        // outer references in its sources resolve at compile time
91        // (SRD 113 step 2).
92        Expr::For(_) => {}
93        Expr::UnaryNeg(inner, _) | Expr::UnaryBitNot(inner, _) | Expr::Cast(inner, _, _) => {
94            collect_expr_refs(inner, out);
95        }
96        Expr::ArrayLit(elems, _) => {
97            for e in elems {
98                collect_expr_refs(e, out);
99            }
100        }
101        Expr::FieldAccess { source, .. } => {
102            // `base.vector` references the source wire `base`.
103            out.insert(source.clone());
104        }
105        Expr::StringLit(s, _) => {
106            collect_string_interpolation_refs(s, out);
107        }
108        Expr::IntLit(..) | Expr::FloatLit(..) => {}
109    }
110}
111
112/// Extract `{name}` interpolation references from a string
113/// literal's contents. Skips format specifiers (`{:05}`,
114/// `{:.2}`) and non-identifier bodies. This is the
115/// string-interpolation grammar — the only place `{name}` is a
116/// reference inside a parsed Polydat expression. (Raw YAML
117/// template fields like an op's `prepared:` use the same
118/// interpolation grammar but never reach the Polydat parser; the
119/// YAML-fusion layer applies [`collect_string_interpolation_refs`]
120/// to those directly.)
121pub fn collect_string_interpolation_refs(s: &str, out: &mut BTreeSet<String>) {
122    let chars: Vec<char> = s.chars().collect();
123    let mut i = 0;
124    while i < chars.len() {
125        if chars[i] != '{' {
126            i += 1;
127            continue;
128        }
129        // Balance nested braces so composite templates
130        // (`{a_{b}_c}`) are spanned as a unit, then recurse into
131        // the body to pick up the inner leaf names.
132        let body_start = i + 1;
133        let mut depth = 1;
134        let mut j = body_start;
135        while j < chars.len() && depth > 0 {
136            match chars[j] {
137                '{' => depth += 1,
138                '}' => depth -= 1,
139                _ => {}
140            }
141            if depth == 0 {
142                break;
143            }
144            j += 1;
145        }
146        if depth != 0 {
147            break; // unmatched `{` — treat the rest as literal
148        }
149        let body: String = chars[body_start..j].iter().collect();
150        if body.contains('{') {
151            // Composite — recurse to collect the inner leaves.
152            collect_string_interpolation_refs(&body, out);
153        } else if is_plain_ident(&body) {
154            out.insert(body);
155        } else {
156            // Expression-bodied placeholder (`{is_one_of(x, "y")}`,
157            // `{mod(hash(cycle), 100)}`) — parse it with the
158            // expression grammar and collect its free names.
159            if let Ok(inner) = try_referenced_names(&body) {
160                out.extend(inner);
161            }
162        }
163        i = j + 1;
164    }
165}
166
167/// True when `s` is a single plain identifier: starts with a
168/// letter or `_`, followed by alphanumerics / `_`.
169fn is_plain_ident(s: &str) -> bool {
170    let mut chars = s.chars();
171    match chars.next() {
172        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
173        _ => return false,
174    }
175    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn names(text: &str) -> Vec<String> {
183        referenced_names(text).into_iter().collect()
184    }
185
186    #[test]
187    fn bare_identifier_is_a_reference() {
188        assert_eq!(names("eh_values"), vec!["eh_values"]);
189    }
190
191    #[test]
192    fn function_call_args_are_references_callee_is_not() {
193        // `concat` is a function name (registry), not a wire ref;
194        // `nbo_v_values` is the argument and IS a reference.
195        assert_eq!(names("concat(nbo_v_values)"), vec!["nbo_v_values"]);
196    }
197
198    #[test]
199    fn nested_calls_collect_all_arg_idents() {
200        let got = names("mod(hash(cycle), p)");
201        assert_eq!(got, vec!["cycle", "p"]);
202    }
203
204    #[test]
205    fn string_literal_interpolation_refs() {
206        let got = names(r#""fknn_oat_{sm_lc}_m{mnc}""#);
207        assert_eq!(got, vec!["mnc", "sm_lc"]);
208    }
209
210    #[test]
211    fn string_literal_plain_text_has_no_refs() {
212        assert!(names(r#""just text""#).is_empty());
213    }
214
215    #[test]
216    fn field_access_references_source_wire() {
217        assert_eq!(names("base.vector"), vec!["base"]);
218    }
219
220    #[test]
221    fn arithmetic_operands_are_references() {
222        let got = names("a + b * c");
223        assert_eq!(got, vec!["a", "b", "c"]);
224    }
225
226    #[test]
227    fn numeric_and_bool_literals_are_not_references() {
228        assert!(names("1000").is_empty());
229        assert!(names("3.14").is_empty());
230        // `true` / `false` lex as identifiers and are excluded by name.
231        assert!(names("true").is_empty());
232    }
233
234    #[test]
235    fn unparseable_text_yields_no_refs_via_lenient_api() {
236        // The lenient API swallows parse errors.
237        assert!(names("((((").is_empty());
238        // The strict API surfaces them.
239        assert!(try_referenced_names("((((").is_err());
240    }
241}