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
58/// `crate::validate::validate_expr`'s reference-collection
59/// arm minus the diagnostics, so the two stay in lockstep about
60/// 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 `crate::validate::collect_references`.
69            if name != "true" && name != "false" {
70                out.insert(name.clone());
71            }
72        }
73        Expr::Call(call) => {
74            // The callee name is a function-registry lookup, not
75            // a wire reference — skip it. Arguments are walked.
76            for arg in &call.args {
77                let inner = match arg {
78                    Arg::Positional(e) => e,
79                    Arg::Named(_, e) => e,
80                };
81                collect_expr_refs(inner, out);
82            }
83        }
84        Expr::BinOp(lhs, _, rhs) => {
85            collect_expr_refs(lhs, out);
86            collect_expr_refs(rhs, out);
87        }
88        // A producer's element names are bound inside its own scope;
89        // outer references in its sources resolve at compile time
90        // (SRD 113 step 2).
91        Expr::For(_) => {}
92        Expr::UnaryNeg(inner, _) | Expr::UnaryBitNot(inner, _) | Expr::Cast(inner, _, _) => {
93            collect_expr_refs(inner, out);
94        }
95        Expr::ArrayLit(elems, _) => {
96            for e in elems {
97                collect_expr_refs(e, out);
98            }
99        }
100        Expr::FieldAccess { source, .. } => {
101            // `base.vector` references the source wire `base`.
102            out.insert(source.clone());
103        }
104        Expr::StringLit(s, _) => {
105            collect_string_interpolation_refs(s, out);
106        }
107        Expr::IntLit(..) | Expr::FloatLit(..) => {}
108    }
109}
110
111/// Extract `{name}` interpolation references from a string
112/// literal's contents. Skips format specifiers (`{:05}`,
113/// `{:.2}`) and non-identifier bodies. This is the
114/// string-interpolation grammar — the only place `{name}` is a
115/// reference inside a parsed Polydat expression. (Raw YAML
116/// template fields like an op's `prepared:` use the same
117/// interpolation grammar but never reach the Polydat parser; the
118/// YAML-fusion layer applies [`collect_string_interpolation_refs`]
119/// to those directly.)
120pub fn collect_string_interpolation_refs(s: &str, out: &mut BTreeSet<String>) {
121    let chars: Vec<char> = s.chars().collect();
122    let mut i = 0;
123    while i < chars.len() {
124        if chars[i] != '{' {
125            i += 1;
126            continue;
127        }
128        // Balance nested braces so composite templates
129        // (`{a_{b}_c}`) are spanned as a unit, then recurse into
130        // the body to pick up the inner leaf names.
131        let body_start = i + 1;
132        let mut depth = 1;
133        let mut j = body_start;
134        while j < chars.len() && depth > 0 {
135            match chars[j] {
136                '{' => depth += 1,
137                '}' => depth -= 1,
138                _ => {}
139            }
140            if depth == 0 {
141                break;
142            }
143            j += 1;
144        }
145        if depth != 0 {
146            break; // unmatched `{` — treat the rest as literal
147        }
148        let body: String = chars[body_start..j].iter().collect();
149        if body.contains('{') {
150            // Composite — recurse to collect the inner leaves.
151            collect_string_interpolation_refs(&body, out);
152        } else if is_plain_ident(&body) {
153            out.insert(body);
154        } else {
155            // Expression-bodied placeholder (`{is_one_of(x, "y")}`,
156            // `{mod(hash(cycle), 100)}`) — parse it with the
157            // expression grammar and collect its free names.
158            if let Ok(inner) = try_referenced_names(&body) {
159                out.extend(inner);
160            }
161        }
162        i = j + 1;
163    }
164}
165
166/// True when `s` is a single plain identifier: starts with a
167/// letter or `_`, followed by alphanumerics / `_`.
168fn is_plain_ident(s: &str) -> bool {
169    let mut chars = s.chars();
170    match chars.next() {
171        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
172        _ => return false,
173    }
174    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn names(text: &str) -> Vec<String> {
182        referenced_names(text).into_iter().collect()
183    }
184
185    #[test]
186    fn bare_identifier_is_a_reference() {
187        assert_eq!(names("eh_values"), vec!["eh_values"]);
188    }
189
190    #[test]
191    fn function_call_args_are_references_callee_is_not() {
192        // `concat` is a function name (registry), not a wire ref;
193        // `nbo_v_values` is the argument and IS a reference.
194        assert_eq!(names("concat(nbo_v_values)"), vec!["nbo_v_values"]);
195    }
196
197    #[test]
198    fn nested_calls_collect_all_arg_idents() {
199        let got = names("mod(hash(cycle), p)");
200        assert_eq!(got, vec!["cycle", "p"]);
201    }
202
203    #[test]
204    fn string_literal_interpolation_refs() {
205        let got = names(r#""fknn_oat_{sm_lc}_m{mnc}""#);
206        assert_eq!(got, vec!["mnc", "sm_lc"]);
207    }
208
209    #[test]
210    fn string_literal_plain_text_has_no_refs() {
211        assert!(names(r#""just text""#).is_empty());
212    }
213
214    #[test]
215    fn field_access_references_source_wire() {
216        assert_eq!(names("base.vector"), vec!["base"]);
217    }
218
219    #[test]
220    fn arithmetic_operands_are_references() {
221        let got = names("a + b * c");
222        assert_eq!(got, vec!["a", "b", "c"]);
223    }
224
225    #[test]
226    fn numeric_and_bool_literals_are_not_references() {
227        assert!(names("1000").is_empty());
228        assert!(names("3.14").is_empty());
229        // `true` / `false` lex as keyword literals, not idents.
230        assert!(names("true").is_empty());
231    }
232
233    #[test]
234    fn unparseable_text_yields_no_refs_via_lenient_api() {
235        // The lenient API swallows parse errors.
236        assert!(names("((((").is_empty());
237        // The strict API surfaces them.
238        assert!(try_referenced_names("((((").is_err());
239    }
240}