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}