Skip to main content

tfparser_core/eval/
registry.rs

1//! Function registry: the set of HCL/Terraform/Terragrunt functions the
2//! evaluator can dispatch to.
3//!
4//! Each function is a small trait object stored under its name in an
5//! `Arc<FuncRegistry>`. Trait objects (not `fn` pointers) are used because
6//! stateful functions (`file()`, `get_env()`, the Terragrunt helpers in
7//! Phase 6) carry per-call context that a bare `fn` pointer cannot capture
8//! — see [93-improvements-review.md] S-011.
9//!
10//! [93-improvements-review.md]: ../../../specs/93-improvements-review.md
11
12use std::{collections::HashMap, fmt, path::Path, sync::Arc};
13
14use thiserror::Error;
15
16use crate::{
17    diagnostic::LimitKind,
18    eval::context::{EnvVarMode, EvalLimits},
19    ir::Value,
20};
21
22/// Per-call context handed to each [`HclFunc::call`].
23///
24/// `CallCx` is intentionally **read-only** so functions cannot mutate the
25/// evaluator's state. Path-safety helpers (`canonicalize_inside`) take
26/// `&Path`, so `workspace_root` is borrowed; the `EnvVarMode` and
27/// `EvalLimits` are likewise references.
28#[derive(Debug)]
29#[non_exhaustive]
30pub struct CallCx<'a> {
31    /// Absolute, canonicalised workspace root for path-sandboxing.
32    pub workspace_root: &'a Path,
33    /// How `get_env(...)` should treat the process environment.
34    pub env_vars: &'a EnvVarMode,
35    /// Resource limits enforced inside function bodies (string size, list
36    /// length, file read size).
37    pub limits: &'a EvalLimits,
38}
39
40impl<'a> CallCx<'a> {
41    /// Construct a new `CallCx` from explicit pieces.
42    #[must_use]
43    pub const fn new(
44        workspace_root: &'a Path,
45        env_vars: &'a EnvVarMode,
46        limits: &'a EvalLimits,
47    ) -> Self {
48        Self {
49            workspace_root,
50            env_vars,
51            limits,
52        }
53    }
54}
55
56/// Error returned by an [`HclFunc::call`].
57///
58/// `FuncError` is the call-site shape; the evaluator converts each variant
59/// to an [`crate::eval::EvalError`] or a [`crate::Diagnostic`] before it
60/// surfaces in `Workspace.diagnostics`. The variants intentionally mirror
61/// the evaluator-level types so downstream tooling sees the same `LimitKind`
62/// regardless of whether the breach happened in the walker or inside a
63/// function body.
64#[derive(Debug, Clone, Error, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum FuncError {
67    /// Wrong number of arguments. `expected` is `usize::MAX` for variadic
68    /// minimum-match failures; messages embed the function-specific shape.
69    #[error("`{name}`: expected {expected} arg(s), got {got}")]
70    Arity {
71        /// Function name.
72        name: Arc<str>,
73        /// Expected arg count (or `usize::MAX` for "variadic ≥ N").
74        expected: usize,
75        /// Observed arg count.
76        got: usize,
77    },
78
79    /// Argument failed its type check. `index` is 0-based.
80    #[error("`{name}` arg #{index}: expected {expected}, got {got}")]
81    Type {
82        /// Function name.
83        name: Arc<str>,
84        /// Argument index (0-based).
85        index: usize,
86        /// Expected type name (e.g. `"string"`, `"number"`).
87        expected: &'static str,
88        /// Observed type name.
89        got: &'static str,
90    },
91
92    /// A function-level limit fired (e.g. result string > `max_str_size`).
93    #[error("`{name}` limit ({kind:?}): observed {observed} > {limit}")]
94    Limit {
95        /// Function name.
96        name: Arc<str>,
97        /// Which limit category fired.
98        kind: LimitKind,
99        /// Observed value.
100        observed: u64,
101        /// Configured limit.
102        limit: u64,
103    },
104
105    /// File function rejected the path because it resolves outside the
106    /// workspace root.
107    #[error("`{name}` path escape: `{path}`")]
108    PathEscape {
109        /// Function name.
110        name: &'static str,
111        /// Path supplied by the caller.
112        path: std::path::PathBuf,
113    },
114
115    /// Anything else. The message is rendered verbatim in diagnostics; it
116    /// should not embed user-controlled data without escaping.
117    #[error("`{name}`: {message}")]
118    Other {
119        /// Function name.
120        name: Arc<str>,
121        /// Free-form message; safe to log.
122        message: Arc<str>,
123    },
124}
125
126/// A single function dispatchable by the evaluator.
127///
128/// Implementations must be `Send + Sync` (the registry is shared across
129/// `rayon` worker threads per [99-key-decisions.md] D14) and `Debug` (per
130/// CLAUDE.md § Type Design).
131///
132/// [99-key-decisions.md]: ../../../specs/99-key-decisions.md
133pub trait HclFunc: fmt::Debug + Send + Sync + 'static {
134    /// Call the function with already-resolved arguments.
135    ///
136    /// `args` is borrowed from the caller's reduced expression tree.
137    /// Returning `Ok(Value)` means the call site collapses to
138    /// [`crate::ir::Expression::Literal`]; returning `Err(_)` keeps the
139    /// call site as an unresolved
140    /// [`crate::ir::Expression::FuncCall`] and the workspace records a
141    /// diagnostic.
142    ///
143    /// # Errors
144    ///
145    /// See [`FuncError`] variants.
146    fn call(&self, args: &[Value], cx: &CallCx<'_>) -> Result<Value, FuncError>;
147}
148
149/// Read-only function registry shared by the evaluator across components.
150///
151/// Construct via [`FuncRegistryBuilder`]. The registry is intentionally
152/// not extendable post-construction so the function table is a stable
153/// `&'static`-shaped object — every later operation against it is a
154/// concurrent read.
155#[derive(Default)]
156pub struct FuncRegistry {
157    funcs: HashMap<Arc<str>, Arc<dyn HclFunc>>,
158}
159
160impl FuncRegistry {
161    /// Look up a function by name.
162    #[must_use]
163    pub fn get(&self, name: &str) -> Option<&Arc<dyn HclFunc>> {
164        self.funcs.get(name)
165    }
166
167    /// Whether a function with this name is registered.
168    #[must_use]
169    pub fn contains(&self, name: &str) -> bool {
170        self.funcs.contains_key(name)
171    }
172
173    /// Iterate over `(name, func)` pairs in arbitrary order. Used by
174    /// diagnostics ("here's the function table the evaluator saw").
175    pub fn iter(&self) -> impl Iterator<Item = (&Arc<str>, &Arc<dyn HclFunc>)> {
176        self.funcs.iter()
177    }
178
179    /// Number of registered functions.
180    #[must_use]
181    pub fn len(&self) -> usize {
182        self.funcs.len()
183    }
184
185    /// Whether the registry is empty.
186    #[must_use]
187    pub fn is_empty(&self) -> bool {
188        self.funcs.is_empty()
189    }
190
191    /// Start a builder from this registry's contents. Used when the spec
192    /// wants "stdlib + overrides".
193    #[must_use]
194    pub fn to_builder(&self) -> FuncRegistryBuilder {
195        FuncRegistryBuilder {
196            funcs: self.funcs.clone(),
197        }
198    }
199
200    /// Build a registry pre-loaded with the Phase 4 default function set:
201    /// HCL stdlib, Terraform-only functions, and the sandboxed file
202    /// functions (`file` / `fileexists` / `templatefile` / `fileset`). The
203    /// sandbox helpers operate on the workspace root supplied via
204    /// [`CallCx::workspace_root`] at call time — no closure state.
205    #[must_use]
206    pub fn default_with_stdlib() -> Self {
207        let mut b = Self::builder();
208        super::stdlib::register(&mut b);
209        super::tf_funcs::register(&mut b);
210        super::files::register(&mut b);
211        b.build()
212    }
213
214    /// Start an empty registry builder.
215    #[must_use]
216    pub fn builder() -> FuncRegistryBuilder {
217        FuncRegistryBuilder::default()
218    }
219}
220
221impl fmt::Debug for FuncRegistry {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        let mut names: Vec<&str> = self.funcs.keys().map(Arc::as_ref).collect();
224        names.sort_unstable();
225        f.debug_struct("FuncRegistry")
226            .field("count", &self.funcs.len())
227            .field("names", &names)
228            .finish()
229    }
230}
231
232/// Mutable builder for a [`FuncRegistry`].
233#[derive(Default)]
234pub struct FuncRegistryBuilder {
235    funcs: HashMap<Arc<str>, Arc<dyn HclFunc>>,
236}
237
238impl FuncRegistryBuilder {
239    /// Register a function by name. Re-registering replaces the existing
240    /// entry — used by tests and by the Phase 6 Terragrunt overlay.
241    pub fn register(&mut self, name: impl Into<Arc<str>>, func: Arc<dyn HclFunc>) -> &mut Self {
242        self.funcs.insert(name.into(), func);
243        self
244    }
245
246    /// Drop an entry by name. No-op if absent.
247    pub fn unregister(&mut self, name: &str) -> &mut Self {
248        self.funcs.remove(name);
249        self
250    }
251
252    /// Whether a name is already registered.
253    #[must_use]
254    pub fn contains(&self, name: &str) -> bool {
255        self.funcs.contains_key(name)
256    }
257
258    /// Freeze into a [`FuncRegistry`].
259    #[must_use]
260    pub fn build(self) -> FuncRegistry {
261        FuncRegistry { funcs: self.funcs }
262    }
263}
264
265impl fmt::Debug for FuncRegistryBuilder {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        f.debug_struct("FuncRegistryBuilder")
268            .field("count", &self.funcs.len())
269            .finish()
270    }
271}
272
273#[cfg(test)]
274#[allow(
275    clippy::unwrap_used,
276    clippy::expect_used,
277    clippy::panic,
278    clippy::indexing_slicing
279)]
280mod tests {
281    use std::path::Path;
282
283    use super::*;
284
285    #[derive(Debug)]
286    struct Echo;
287    impl HclFunc for Echo {
288        fn call(&self, args: &[Value], _cx: &CallCx<'_>) -> Result<Value, FuncError> {
289            args.first().cloned().ok_or_else(|| FuncError::Arity {
290                name: Arc::from("echo"),
291                expected: 1,
292                got: 0,
293            })
294        }
295    }
296
297    fn fake_cx() -> (EvalLimits, EnvVarMode) {
298        (EvalLimits::default(), EnvVarMode::default())
299    }
300
301    #[test]
302    fn test_registry_builder_registers_and_unregisters() {
303        let mut b = FuncRegistry::builder();
304        b.register("echo", Arc::new(Echo));
305        assert!(b.contains("echo"));
306        b.unregister("echo");
307        assert!(!b.contains("echo"));
308    }
309
310    #[test]
311    fn test_registry_dispatch_echo() {
312        let mut b = FuncRegistry::builder();
313        b.register("echo", Arc::new(Echo));
314        let r = b.build();
315        let (limits, env_vars) = fake_cx();
316        let cx = CallCx {
317            workspace_root: Path::new("/tmp"),
318            env_vars: &env_vars,
319            limits: &limits,
320        };
321        let v = r.get("echo").unwrap().call(&[Value::Int(7)], &cx).unwrap();
322        assert_eq!(v, Value::Int(7));
323    }
324
325    #[test]
326    fn test_default_with_stdlib_has_known_functions() {
327        let r = FuncRegistry::default_with_stdlib();
328        // Spot-check a few canonical entries from spec 13 § 5.
329        assert!(r.contains("jsonencode"), "{r:?}");
330        assert!(r.contains("merge"), "{r:?}");
331        assert!(r.contains("sha256"), "{r:?}");
332        assert!(r.contains("base64encode"), "{r:?}");
333    }
334
335    #[test]
336    fn test_func_error_arity_renders_function_name() {
337        let e = FuncError::Arity {
338            name: Arc::from("merge"),
339            expected: 2,
340            got: 1,
341        };
342        let s = format!("{e}");
343        assert!(s.contains("merge"));
344        assert!(s.contains('2'));
345        assert!(s.contains('1'));
346    }
347
348    #[test]
349    fn test_registry_is_send_sync() {
350        const fn assert_send_sync<T: Send + Sync + 'static>() {}
351        assert_send_sync::<FuncRegistry>();
352        assert_send_sync::<Arc<FuncRegistry>>();
353        assert_send_sync::<Arc<dyn HclFunc>>();
354    }
355
356    #[test]
357    fn test_registry_debug_lists_sorted_names() {
358        let mut b = FuncRegistry::builder();
359        b.register("z_alpha", Arc::new(Echo));
360        b.register("a_alpha", Arc::new(Echo));
361        let r = b.build();
362        let s = format!("{r:?}");
363        let z_pos = s.find("z_alpha").expect("z_alpha present");
364        let a_pos = s.find("a_alpha").expect("a_alpha present");
365        assert!(a_pos < z_pos, "expected sorted names: {s}");
366    }
367}