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}