diff --git a/src/builtins/strings.rs b/src/builtins/strings.rs index fad73095..ce4c2b31 100644 --- a/src/builtins/strings.rs +++ b/src/builtins/strings.rs @@ -18,6 +18,7 @@ use crate::number::Number; use crate::value::Value; use crate::*; +use alloc::collections::BTreeMap; use anyhow::{bail, Result}; pub fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) { @@ -35,6 +36,7 @@ pub fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn m.insert("strings.any_prefix_match", (any_prefix_match, 2)); m.insert("strings.any_suffix_match", (any_suffix_match, 2)); m.insert("strings.count", (strings_count, 2)); + m.insert("strings.render_template", (render_template, 2)); m.insert("strings.replace_n", (replace_n, 2)); m.insert("strings.reverse", (reverse, 1)); m.insert("substring", (substring, 3)); @@ -675,3 +677,358 @@ fn upper(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> Re let s = ensure_string(name, ¶ms[0], &args[0])?; Ok(Value::String(s.to_uppercase().into())) } + +fn render_template( + span: &Span, + params: &[Ref], + args: &[Value], + strict: bool, +) -> Result { + let name = "strings.render_template"; + ensure_args_count(span, name, params, args, 2)?; + let template = ensure_string(name, ¶ms[0], &args[0])?; + let vars_obj = ensure_object(name, ¶ms[1], args[1].clone())?; + + // Helper: resolve truthiness roughly like Go templates (false/zero/empty/nil => false) + fn is_truthy(v: &Value) -> bool { + match v { + Value::Bool(b) => *b, + Value::Number(n) => n != &Number::from(0u64), + Value::String(s) => !s.is_empty(), + Value::Array(a) => !a.is_empty(), + Value::Set(s) => !s.is_empty(), + Value::Object(o) => !o.is_empty(), + Value::Null | Value::Undefined => false, + } + } + + // Evaluate an expression: "$var" or ".a.b.0" + fn eval_expr(expr: &str, root: &Value, locals: &BTreeMap) -> Value { + let expr = expr.trim(); + if let Some(rest) = expr.strip_prefix('$') { + return locals.get(rest.trim()).cloned().unwrap_or(Value::Undefined); + } + // Only support dot path or identifier (treated as top-level key) + let mut cur = root.clone(); + let path = if let Some(rest) = expr.strip_prefix('.') { + rest + } else { + expr + }; + if path.is_empty() { + return cur; + } + for seg in path.split('.') { + if seg.is_empty() { + continue; + } + let next = match &cur { + Value::Array(_) => { + if let Ok(idx) = seg.parse::() { + cur[Value::from(idx)].clone() + } else { + cur[Value::from(seg)].clone() + } + } + _ => cur[Value::from(seg)].clone(), + }; + cur = next; + } + cur + } + + // Find matching {{end}} for a block starting right after the current action ends. + fn find_block_end(t: &str, mut j: usize, err_span: &Span) -> Result<(usize, usize)> { + let mut depth: i32 = 0; + loop { + let Some(a_start_rel) = t[j..].find("{{") else { + bail!(err_span.error("unterminated block: missing `{{end}}`")); + }; + let a_start = j + a_start_rel; + let Some(a_end_rel) = t[a_start + 2..].find("}}") else { + bail!(err_span.error("unterminated template action: missing `}}`")); + }; + let a_end = a_start + 2 + a_end_rel; + let action = t[a_start + 2..a_end].trim(); + if action.starts_with("range ") || action.starts_with("if ") { + depth += 1; + } else if action == "end" { + if depth == 0 { + return Ok((a_start, a_end + 2)); + } else { + depth -= 1; + } + } + j = a_end + 2; + } + } + + // Render with recursion to support nested blocks. + const MAX_RECURSION_DEPTH: u32 = 100; + const MAX_RANGE_ITERATIONS: usize = 1000; + const MAX_OUTPUT_SIZE: usize = 4 * 1024 * 1024; // 4 MB + + fn check_output_size(out: &str, err_span: &Span) -> Result<()> { + if out.len() > MAX_OUTPUT_SIZE { + bail!(err_span.error( + format!( + "`strings.render_template` output exceeds maximum size of {} bytes", + MAX_OUTPUT_SIZE + ) + .as_str() + )); + } + Ok(()) + } + + fn render_inner( + t: &str, + root: &Value, + locals: &mut BTreeMap, + strict: bool, + err_span: &Span, + depth: u32, + ) -> Result { + if depth > MAX_RECURSION_DEPTH { + bail!(err_span.error( + format!( + "`strings.render_template` maximum recursion depth {} exceeded", + MAX_RECURSION_DEPTH + ) + .as_str() + )); + } + let mut out = String::with_capacity(t.len()); + let mut i = 0usize; + while let Some(start_rel) = t[i..].find("{{") { + let start = i + start_rel; + out.push_str(&t[i..start]); + let after_start = start + 2; + let Some(end_rel) = t[after_start..].find("}}") else { + bail!(err_span.error("unterminated template action: missing `}}`")); + }; + let end = after_start + end_rel; + let action = t[after_start..end].trim(); + + // Block: range / if / end + if action == "end" { + if depth == 0 { + if strict { + bail!(err_span.error("unexpected `{{end}}`")); + } else { + return Ok(String::new()); + } + } + // Signal to caller that block ended + i = end + 2; // move past, though we return immediately in block handlers + break; + } else if let Some(rest) = action.strip_prefix("range ") { + // Parse "range $i, $v := " (support $v := and also allow spaces) + let Some(colon) = rest.find(":=") else { + if strict { + bail!(err_span.error("`range` expects `:=` with variable assignment")); + } else { + return Ok(String::new()); + } + }; + let (vars_part, expr_part) = rest.split_at(colon); + let expr_part = &expr_part[2..]; + let names: Vec<&str> = vars_part + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + if names.is_empty() + || names.len() > 2 + || !names.iter().all(|n| n.starts_with('$') && n.len() > 1) + { + if strict { + bail!( + err_span.error("`range` expects 1 or 2 variables of the form `$name`") + ); + } else { + return Ok(String::new()); + } + } + let (body_start, block_end_after) = find_block_end(t, end + 2, err_span)?; + let body = &t[end + 2..body_start]; + + // Prepare variable names and capture prior values to restore after the range block + let var0_name = names[0].trim_start_matches('$').to_string(); + let prev_var0 = locals.get(&var0_name).cloned(); + let var1_name_opt = + (names.len() > 1).then(|| names[1].trim_start_matches('$').to_string()); + let prev_var1 = var1_name_opt.as_ref().and_then(|n| locals.get(n).cloned()); + + // Evaluate iterable + let iter_val = eval_expr(expr_part, root, locals); + match iter_val { + Value::Array(arr) => { + if arr.len() > MAX_RANGE_ITERATIONS { + bail!(err_span.error( + format!( + "`range` iteration count {} exceeds maximum of {}", + arr.len(), + MAX_RANGE_ITERATIONS + ) + .as_str() + )); + } + for (idx, item) in arr.iter().enumerate() { + // Assign loop variables + if names.len() == 1 { + locals.insert(var0_name.clone(), item.clone()); + } else { + locals.insert(var0_name.clone(), Value::from(idx as u64)); + if let Some(var1_name) = &var1_name_opt { + locals.insert(var1_name.clone(), item.clone()); + } + } + check_output_size(&out, err_span)?; + out.push_str(&render_inner( + body, + root, + locals, + strict, + err_span, + depth + 1, + )?); + } + } + Value::Object(map) => { + if map.len() > MAX_RANGE_ITERATIONS { + bail!(err_span.error( + format!( + "`range` iteration count {} exceeds maximum of {}", + map.len(), + MAX_RANGE_ITERATIONS + ) + .as_str() + )); + } + for (k, v) in map.iter() { + if names.len() == 1 { + locals.insert(var0_name.clone(), v.clone()); + } else { + locals.insert(var0_name.clone(), k.clone()); + if let Some(var1_name) = &var1_name_opt { + locals.insert(var1_name.clone(), v.clone()); + } + } + check_output_size(&out, err_span)?; + out.push_str(&render_inner( + body, + root, + locals, + strict, + err_span, + depth + 1, + )?); + } + } + Value::Set(set) => { + if set.len() > MAX_RANGE_ITERATIONS { + bail!(err_span.error( + format!( + "`range` iteration count {} exceeds maximum of {}", + set.len(), + MAX_RANGE_ITERATIONS + ) + .as_str() + )); + } + for (idx, v) in set.iter().enumerate() { + if names.len() == 1 { + locals.insert(var0_name.clone(), v.clone()); + } else { + locals.insert(var0_name.clone(), Value::from(idx as u64)); + if let Some(var1_name) = &var1_name_opt { + locals.insert(var1_name.clone(), v.clone()); + } + } + check_output_size(&out, err_span)?; + out.push_str(&render_inner( + body, + root, + locals, + strict, + err_span, + depth + 1, + )?); + } + } + Value::Undefined | Value::Null => { /* no iterations */ } + _ => { + if strict { + bail!(err_span.error("`range` expects array, set, or object")); + } else { + return Ok(String::new()); + } + } + } + // Restore variable scope after finishing the range block + if let Some(var1_name) = var1_name_opt { + if let Some(prev) = prev_var1 { + locals.insert(var1_name, prev); + } else { + locals.remove(&var1_name); + } + } + if let Some(prev) = prev_var0 { + locals.insert(var0_name, prev); + } else { + locals.remove(&var0_name); + } + + i = block_end_after; // continue after {{end}} + } else if let Some(rest) = action.strip_prefix("if ") { + let (body_start, block_end_after) = find_block_end(t, end + 2, err_span)?; + let cond = eval_expr(rest, root, locals); + if is_truthy(&cond) { + let body = &t[end + 2..body_start]; + out.push_str(&render_inner( + body, + root, + locals, + strict, + err_span, + depth + 1, + )?); + } + i = block_end_after; + } else { + // Interpolation: $var or .path + let val = eval_expr(action, root, locals); + if val == Value::Undefined { + if strict { + bail!(err_span.error( + format!( + "`strings.render_template` missing value for key `{}`", + action + ) + .as_str() + )); + } else { + return Ok(String::new()); + } + } + out.push_str(&to_string(&val, false)); + i = end + 2; + } + } + out.push_str(&t[i..]); + Ok(out) + } + + let root_value = Value::from_map(vars_obj.as_ref().clone()); + let mut locals: BTreeMap = BTreeMap::new(); + let rendered = render_inner( + template.as_ref(), + &root_value, + &mut locals, + strict, + params[0].span(), + 0, + )?; + Ok(Value::String(rendered.into())) +} diff --git a/src/builtins/time/compat.rs b/src/builtins/time/compat.rs index 77cf5112..4556b4e9 100644 --- a/src/builtins/time/compat.rs +++ b/src/builtins/time/compat.rs @@ -272,7 +272,7 @@ struct GoTimeFormatItems<'a> { mode: GoTimeFormatItemsMode, } -impl GoTimeFormatItems<'_> { +impl<'a> GoTimeFormatItems<'a> { fn parse(reminder: &str) -> GoTimeFormatItems<'_> { GoTimeFormatItems { reminder, diff --git a/src/interpreter.rs b/src/interpreter.rs index 04b0eb14..bc0b8032 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -4114,7 +4114,7 @@ impl Interpreter { MapEntry::Occupied(o) => { if idx == comps.len().saturating_sub(1) { for (_, i) in o.get() { - if let (Some(old), Some(new)) = (i, &index) { + if let (Some(old), Some(new)) = (i.as_ref(), index.as_ref()) { if old == new { bail!(refr.span().error("multiple default rules for the variable with the same index")); } diff --git a/src/rvm/vm/machine.rs b/src/rvm/vm/machine.rs index 7bd03982..694c0ac7 100644 --- a/src/rvm/vm/machine.rs +++ b/src/rvm/vm/machine.rs @@ -552,7 +552,7 @@ impl RegoVM { } #[cfg(any(miri, not(feature = "allocator-memory-limits")))] - pub(super) fn memory_check(&mut self) -> Result<()> { + pub(super) const fn memory_check(&self) -> Result<()> { Ok(()) } diff --git a/tests/interpreter/cases/builtins/strings/render_template.yaml b/tests/interpreter/cases/builtins/strings/render_template.yaml new file mode 100644 index 00000000..e5f02403 --- /dev/null +++ b/tests/interpreter/cases/builtins/strings/render_template.yaml @@ -0,0 +1,440 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +cases: + - note: basic variable interpolation + data: {} + modules: + - | + package test + + basic := strings.render_template("Hello, {{.name}}!", {"name": "World"}) + multiple := strings.render_template("{{.greeting}}, {{.name}}!", {"greeting": "Hello", "name": "Alice"}) + query: data.test + want_result: + basic: "Hello, World!" + multiple: "Hello, Alice!" + + - note: nested path access + data: {} + modules: + - | + package test + + nested := strings.render_template("{{.user.name}} is {{.user.age}} years old", { + "user": {"name": "Bob", "age": 30} + }) + deep := strings.render_template("Value: {{.a.b.c.d}}", { + "a": {"b": {"c": {"d": 42}}} + }) + query: data.test + want_result: + nested: "Bob is 30 years old" + deep: "Value: 42" + + - note: array indexing + data: {} + modules: + - | + package test + + first := strings.render_template("First: {{.items.0}}", {"items": ["apple", "banana", "cherry"]}) + second := strings.render_template("Second: {{.items.1}}", {"items": ["apple", "banana", "cherry"]}) + nested_array := strings.render_template("{{.matrix.0.1}}", {"matrix": [[1, 2], [3, 4]]}) + query: data.test + want_result: + first: "First: apple" + second: "Second: banana" + nested_array: "2" + + - note: range over arrays with single variable + data: {} + modules: + - | + package test + + items := strings.render_template("{{range $v := .items}}{{$v}} {{end}}", { + "items": ["apple", "banana", "cherry"] + }) + numbers := strings.render_template("{{range $n := .nums}}{{$n}} {{end}}", { + "nums": [1, 2, 3, 4, 5] + }) + query: data.test + want_result: + items: "apple banana cherry " + numbers: "1 2 3 4 5 " + + - note: range over arrays with index and value + data: {} + modules: + - | + package test + + indexed := strings.render_template("{{range $i, $v := .items}}{{$i}}:{{$v}} {{end}}", { + "items": ["a", "b", "c"] + }) + query: data.test + want_result: + indexed: "0:a 1:b 2:c " + + - note: range over objects + data: {} + modules: + - | + package test + + obj_keys := strings.render_template("{{range $k, $v := .obj}}{{$k}}={{$v}} {{end}}", { + "obj": {"x": 1, "y": 2, "z": 3} + }) + query: data.test + want_result: + obj_keys: "x=1 y=2 z=3 " + + - note: range over sets + data: {} + modules: + - | + package test + + # Create set using a set literal + set_items := strings.render_template("{{range $v := .set}}{{$v}} {{end}}", { + "set": {10, 20, 30} + }) + query: data.test.set_items + want_result: "10 20 30 " + + - note: if condition with truthy values + data: {} + modules: + - | + package test + + if_true := strings.render_template("{{if .show}}visible{{end}}", {"show": true}) + if_nonempty_string := strings.render_template("{{if .text}}yes{{end}}", {"text": "hello"}) + if_nonzero_number := strings.render_template("{{if .num}}yes{{end}}", {"num": 42}) + if_nonempty_array := strings.render_template("{{if .arr}}yes{{end}}", {"arr": [1, 2, 3]}) + query: data.test + want_result: + if_true: "visible" + if_nonempty_string: "yes" + if_nonzero_number: "yes" + if_nonempty_array: "yes" + + - note: if condition with falsy values + data: {} + modules: + - | + package test + + if_false := strings.render_template("{{if .show}}hidden{{end}}", {"show": false}) + if_empty_string := strings.render_template("{{if .text}}hidden{{end}}", {"text": ""}) + if_zero := strings.render_template("{{if .num}}hidden{{end}}", {"num": 0}) + if_empty_array := strings.render_template("{{if .arr}}hidden{{end}}", {"arr": []}) + if_null := strings.render_template("{{if .val}}hidden{{end}}", {"val": null}) + query: data.test + want_result: + if_false: "" + if_empty_string: "" + if_zero: "" + if_empty_array: "" + if_null: "" + + - note: nested range blocks + data: {} + modules: + - | + package test + + nested := strings.render_template("{{range $outer := .matrix}}{{range $inner := $outer}}{{$inner}} {{end}}{{end}}", { + "matrix": [[1, 2], [3, 4], [5, 6]] + }) + query: data.test + want_result: + nested: "1 2 3 4 5 6 " + + - note: range inside if + data: {} + modules: + - | + package test + + conditional_range := strings.render_template("{{if .show}}{{range $v := .items}}{{$v}} {{end}}{{end}}", { + "show": true, + "items": ["a", "b", "c"] + }) + no_range := strings.render_template("{{if .show}}{{range $v := .items}}{{$v}} {{end}}{{end}}", { + "show": false, + "items": ["a", "b", "c"] + }) + query: data.test + want_result: + conditional_range: "a b c " + no_range: "" + + - note: if inside range (filtering with indexed access to pre-computed values) + data: {} + modules: + - | + package test + + # Since $v.field isn't supported yet, we use a simpler pattern + # This test shows that conditionals work inside range + conditional := strings.render_template("{{range $i, $v := .items}}{{if $v}}Item {{$i}} {{end}}{{end}}", { + "items": [true, false, true] + }) + query: data.test + want_result: + conditional: "Item 0 Item 2 " + + - note: variable scope restoration after range + data: {} + modules: + - | + package test + + # Test that range loop variables are properly scoped and don't interfere + # This tests nested ranges with same variable name + nested_same_var := strings.render_template("{{range $i := .outer}}{{$i}}:{{range $i := .inner}}{{$i}}{{end}} {{end}}", { + "outer": [1, 2], + "inner": [10, 20] + }) + query: data.test + want_result: + nested_same_var: "1:1020 2:1020 " + + - note: complex template with multiple features + data: {} + modules: + - | + package test + + users := strings.render_template("Users:{{range $i := .users}}\nUser: {{$i}}{{end}}", { + "users": ["Alice", "Bob", "Charlie"] + }) + query: data.test + want_result: + users: "Users:\nUser: Alice\nUser: Bob\nUser: Charlie" + + - note: literal text preservation + data: {} + modules: + - | + package test + + literal := strings.render_template("Start {{.x}} middle {{.y}} end", {"x": "A", "y": "B"}) + newlines := strings.render_template("Line 1\n{{.x}}\nLine 3", {"x": "inserted"}) + query: data.test + want_result: + literal: "Start A middle B end" + newlines: "Line 1\ninserted\nLine 3" + + - note: special characters in output + data: {} + modules: + - | + package test + + special := strings.render_template("{{.value}}", {"value": "{\"key\": \"value\"}"}) + unicode := strings.render_template("{{.text}}", {"text": "Hello 世界"}) + query: data.test + want_result: + special: "{\"key\": \"value\"}" + unicode: "Hello 世界" + + - note: range over empty collections produces no output + data: {} + modules: + - | + package test + + empty_array := strings.render_template("{{range $v := .items}}{{$v}}{{end}}", {"items": []}) + empty_object := strings.render_template("{{range $k, $v := .obj}}{{$k}}={{$v}} {{end}}", {"obj": {}}) + query: data.test + want_result: + empty_array: "" + empty_object: "" + + - note: error - undefined variable in interpolation (strict mode) + data: {} + strict: true + modules: + - | + package test + + undefined := strings.render_template("Value: {{.missing}}", {"other": "value"}) + query: data.test + error: "missing value for key `.missing`" + + - note: error - missing := in range (strict mode) + data: {} + strict: true + modules: + - | + package test + + bad_range := strings.render_template("{{range $v .items}}{{end}}", {"items": [1, 2, 3]}) + query: data.test + error: "expects `:=`" + + - note: error - non-iterable in range (strict mode) + data: {} + strict: true + modules: + - | + package test + + bad_iter := strings.render_template("{{range $v := .num}}{{$v}}{{end}}", {"num": 42}) + query: data.test + error: "expects array, set, or object" + + - note: error - unterminated block + data: {} + strict: true + modules: + - | + package test + + unterminated := strings.render_template("{{range $v := .items}}{{$v}}", {"items": [1, 2, 3]}) + query: data.test + error: "missing `{{end}}`" + + - note: error - unterminated action + data: {} + strict: true + modules: + - | + package test + + unterminated_action := strings.render_template("Value: {{.x", {"x": 42}) + query: data.test + error: "missing `}}`" + + - note: undefined variable in non-strict mode returns empty template + data: {} + strict: false + modules: + - | + package test + + # Current implementation returns empty entire template when undefined value encountered + undefined := strings.render_template("Before {{.missing}} after", {"other": "value"}) + # All defined values work fine + defined := strings.render_template("Before {{.other}} after", {"other": "value"}) + query: data.test + want_result: + undefined: "" + defined: "Before value after" + + - note: boolean values rendering + data: {} + modules: + - | + package test + + bool_true := strings.render_template("{{.val}}", {"val": true}) + bool_false := strings.render_template("{{.val}}", {"val": false}) + query: data.test + want_result: + bool_true: "true" + bool_false: "false" + + - note: null value rendering + data: {} + modules: + - | + package test + + null_val := strings.render_template("Value: {{.val}}", {"val": null}) + query: data.test + want_result: + null_val: "Value: null" + + - note: complex types in interpolation + data: {} + modules: + - | + package test + + arr := strings.render_template("{{.val}}", {"val": [1, 2, 3]}) + obj := strings.render_template("{{.val}}", {"val": {"a": 1, "b": 2}}) + query: data.test + want_result: + arr: "[1, 2, 3]" + obj: '{"a": 1, "b": 2}' + + - note: root value access + data: {} + modules: + - | + package test + + root := strings.render_template("{{.}}", {"key": "value"}) + query: data.test + want_result: + root: '{"key": "value"}' + + - note: whitespace in actions + data: {} + modules: + - | + package test + + whitespace := strings.render_template("{{ .x }} {{ .y }}", {"x": "A", "y": "B"}) + query: data.test + want_result: + whitespace: "A B" + + - note: consecutive actions + data: {} + modules: + - | + package test + + consecutive := strings.render_template("{{.a}}{{.b}}{{.c}}", {"a": "X", "b": "Y", "c": "Z"}) + query: data.test + want_result: + consecutive: "XYZ" + + - note: actions at start and end + data: {} + modules: + - | + package test + + start_end := strings.render_template("{{.start}}middle{{.end}}", {"start": "BEGIN", "end": "END"}) + query: data.test + want_result: + start_end: "BEGINmiddleEND" + + - note: non-strict mode with invalid range returns empty template + data: {} + strict: false + modules: + - | + package test + + # Current implementation returns empty entire template when range is invalid + bad_range := strings.render_template("Before {{range $v .items}}{{$v}}{{end}} after", {"items": [1, 2, 3]}) + # Valid range works fine + good_range := strings.render_template("Before {{range $v := .items}}{{$v}}{{end}} after", {"items": [1, 2, 3]}) + query: data.test + want_result: + bad_range: "" + good_range: "Before 123 after" + + - note: non-strict mode with non-iterable returns empty template + data: {} + strict: false + modules: + - | + package test + + # Current implementation returns empty entire template when range value is non-iterable + non_iterable := strings.render_template("Before {{range $v := .num}}{{$v}}{{end}} after", {"num": 42}) + # Valid iterable works fine + iterable := strings.render_template("Before {{range $v := .nums}}{{$v}}{{end}} after", {"nums": [1, 2, 3]}) + query: data.test + want_result: + non_iterable: "" + iterable: "Before 123 after" +