Skip to content

Commit da9fa33

Browse files
committed
feat: support strings.render_template
1 parent 12c083e commit da9fa33

3 files changed

Lines changed: 258 additions & 2 deletions

File tree

src/builtins/strings.rs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crate::number::Number;
1212
use crate::value::Value;
1313
use crate::*;
1414

15+
use alloc::collections::BTreeMap;
1516
use anyhow::{bail, Result};
1617

1718
pub fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) {
@@ -29,6 +30,7 @@ pub fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn
2930
m.insert("strings.any_prefix_match", (any_prefix_match, 2));
3031
m.insert("strings.any_suffix_match", (any_suffix_match, 2));
3132
m.insert("strings.count", (strings_count, 2));
33+
m.insert("strings.render_template", (render_template, 2));
3234
m.insert("strings.replace_n", (replace_n, 2));
3335
m.insert("strings.reverse", (reverse, 1));
3436
m.insert("substring", (substring, 3));
@@ -655,3 +657,257 @@ fn upper(span: &Span, params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Re
655657
let s = ensure_string(name, &params[0], &args[0])?;
656658
Ok(Value::String(s.to_uppercase().into()))
657659
}
660+
661+
fn render_template(
662+
span: &Span,
663+
params: &[Ref<Expr>],
664+
args: &[Value],
665+
strict: bool,
666+
) -> Result<Value> {
667+
let name = "strings.render_template";
668+
ensure_args_count(span, name, params, args, 2)?;
669+
let template = ensure_string(name, &params[0], &args[0])?;
670+
let vars_obj = ensure_object(name, &params[1], args[1].clone())?;
671+
672+
// Helper: resolve truthiness roughly like Go templates (false/zero/empty/nil => false)
673+
fn is_truthy(v: &Value) -> bool {
674+
match v {
675+
Value::Bool(b) => *b,
676+
Value::Number(n) => n != &Number::from(0u64),
677+
Value::String(s) => !s.is_empty(),
678+
Value::Array(a) => !a.is_empty(),
679+
Value::Set(s) => !s.is_empty(),
680+
Value::Object(o) => !o.is_empty(),
681+
Value::Null | Value::Undefined => false,
682+
}
683+
}
684+
685+
// Evaluate an expression: "$var" or ".a.b.0"
686+
fn eval_expr(expr: &str, root: &Value, locals: &BTreeMap<String, Value>) -> Value {
687+
let expr = expr.trim();
688+
if let Some(rest) = expr.strip_prefix('$') {
689+
return locals.get(rest.trim()).cloned().unwrap_or(Value::Undefined);
690+
}
691+
// Only support dot path or identifier (treated as top-level key)
692+
let mut cur = root.clone();
693+
let path = if let Some(rest) = expr.strip_prefix('.') {
694+
rest
695+
} else {
696+
expr
697+
};
698+
if path.is_empty() {
699+
return cur;
700+
}
701+
for seg in path.split('.') {
702+
if seg.is_empty() {
703+
continue;
704+
}
705+
let next = if let Ok(idx) = seg.parse::<u64>() {
706+
cur[Value::from(idx)].clone()
707+
} else {
708+
cur[Value::from(seg)].clone()
709+
};
710+
cur = next;
711+
}
712+
cur
713+
}
714+
715+
// Find matching {{end}} for a block starting right after the current action ends.
716+
fn find_block_end(t: &str, mut j: usize, err_span: &Span) -> Result<(usize, usize)> {
717+
let mut depth: i32 = 0;
718+
loop {
719+
let Some(a_start_rel) = t[j..].find("{{") else {
720+
bail!(err_span.error("unterminated block: missing `{{end}}`"));
721+
};
722+
let a_start = j + a_start_rel;
723+
let Some(a_end_rel) = t[a_start + 2..].find("}}") else {
724+
bail!(err_span.error("unterminated template action: missing `}}`"));
725+
};
726+
let a_end = a_start + 2 + a_end_rel;
727+
let action = t[a_start + 2..a_end].trim();
728+
if action.starts_with("range ") || action.starts_with("if ") {
729+
depth += 1;
730+
} else if action == "end" {
731+
if depth == 0 {
732+
return Ok((a_start, a_end + 2));
733+
} else {
734+
depth -= 1;
735+
}
736+
}
737+
j = a_end + 2;
738+
}
739+
}
740+
741+
// Render with recursion to support nested blocks.
742+
fn render_inner(
743+
t: &str,
744+
root: &Value,
745+
locals: &mut BTreeMap<String, Value>,
746+
strict: bool,
747+
err_span: &Span,
748+
) -> Result<String> {
749+
let mut out = String::with_capacity(t.len());
750+
let mut i = 0usize;
751+
while let Some(start_rel) = t[i..].find("{{") {
752+
let start = i + start_rel;
753+
out.push_str(&t[i..start]);
754+
let after_start = start + 2;
755+
let Some(end_rel) = t[after_start..].find("}}") else {
756+
bail!(err_span.error("unterminated template action: missing `}}`"));
757+
};
758+
let end = after_start + end_rel;
759+
let action = t[after_start..end].trim();
760+
761+
// Block: range / if / end
762+
if action == "end" {
763+
// Signal to caller that block ended (should not occur at top level)
764+
i = end + 2; // move past, though we return immediately in block handlers
765+
break;
766+
} else if let Some(rest) = action.strip_prefix("range ") {
767+
// Parse "range $i, $v := <expr>" (support $v := <expr> and also allow spaces)
768+
let Some(colon) = rest.find(":=") else {
769+
if strict {
770+
bail!(err_span.error("`range` expects `:=` with variable assignment"));
771+
} else {
772+
return Ok(String::new());
773+
}
774+
};
775+
let (vars_part, expr_part) = rest.split_at(colon);
776+
let expr_part = &expr_part[2..];
777+
let names: Vec<&str> = vars_part
778+
.split(',')
779+
.map(|s| s.trim())
780+
.filter(|s| !s.is_empty())
781+
.collect();
782+
if names.is_empty() || !names.iter().all(|n| n.starts_with('$')) {
783+
if strict {
784+
bail!(err_span.error("`range` expects variables starting with `$`"));
785+
} else {
786+
return Ok(String::new());
787+
}
788+
}
789+
let (body_start, block_end_after) = find_block_end(t, end + 2, err_span)?;
790+
let body = &t[end + 2..body_start];
791+
792+
// Prepare variable names and capture prior values to restore after the range block
793+
let var0_name = names[0].trim_start_matches('$').to_string();
794+
let prev_var0 = locals.get(&var0_name).cloned();
795+
let var1_name_opt = if names.len() > 1 {
796+
Some(names[1].trim_start_matches('$').to_string())
797+
} else {
798+
None
799+
};
800+
let prev_var1 = var1_name_opt.as_ref().and_then(|n| locals.get(n).cloned());
801+
802+
// Evaluate iterable
803+
let iter_val = eval_expr(expr_part, root, locals);
804+
match iter_val {
805+
Value::Array(arr) => {
806+
for (idx, item) in arr.iter().enumerate() {
807+
// Assign loop variables
808+
if names.len() == 1 {
809+
locals.insert(var0_name.clone(), item.clone());
810+
} else {
811+
locals.insert(var0_name.clone(), Value::from(idx as u64));
812+
if let Some(var1_name) = &var1_name_opt {
813+
locals.insert(var1_name.clone(), item.clone());
814+
}
815+
}
816+
out.push_str(&render_inner(body, root, locals, strict, err_span)?);
817+
}
818+
}
819+
Value::Object(map) => {
820+
for (k, v) in map.iter() {
821+
if names.len() == 1 {
822+
locals.insert(var0_name.clone(), v.clone());
823+
} else {
824+
locals.insert(var0_name.clone(), k.clone());
825+
if let Some(var1_name) = &var1_name_opt {
826+
locals.insert(var1_name.clone(), v.clone());
827+
}
828+
}
829+
out.push_str(&render_inner(body, root, locals, strict, err_span)?);
830+
}
831+
}
832+
Value::Set(set) => {
833+
for (idx, v) in set.iter().enumerate() {
834+
if names.len() == 1 {
835+
locals.insert(var0_name.clone(), v.clone());
836+
} else {
837+
locals.insert(var0_name.clone(), Value::from(idx as u64));
838+
if let Some(var1_name) = &var1_name_opt {
839+
locals.insert(var1_name.clone(), v.clone());
840+
}
841+
}
842+
out.push_str(&render_inner(body, root, locals, strict, err_span)?);
843+
}
844+
}
845+
Value::Undefined | Value::Null => { /* no iterations */ }
846+
_ => {
847+
if strict {
848+
bail!(err_span.error("`range` expects array, set, or object"));
849+
} else {
850+
return Ok(String::new());
851+
}
852+
}
853+
}
854+
// Restore variable scope after finishing the range block
855+
if let Some(var1_name) = var1_name_opt {
856+
if let Some(prev) = prev_var1 {
857+
locals.insert(var1_name, prev);
858+
} else {
859+
locals.remove(&var1_name);
860+
}
861+
}
862+
if let Some(prev) = prev_var0 {
863+
locals.insert(var0_name, prev);
864+
} else {
865+
locals.remove(&var0_name);
866+
}
867+
868+
i = block_end_after; // continue after {{end}}
869+
continue;
870+
} else if let Some(rest) = action.strip_prefix("if ") {
871+
let (body_start, block_end_after) = find_block_end(t, end + 2, err_span)?;
872+
let cond = eval_expr(rest, root, locals);
873+
if is_truthy(&cond) {
874+
let body = &t[end + 2..body_start];
875+
out.push_str(&render_inner(body, root, locals, strict, err_span)?);
876+
}
877+
i = block_end_after;
878+
continue;
879+
} else {
880+
// Interpolation: $var or .path
881+
let val = eval_expr(action, root, locals);
882+
if val == Value::Undefined {
883+
if strict {
884+
bail!(err_span.error(
885+
format!(
886+
"`strings.render_template` missing value for key `{}`",
887+
action
888+
)
889+
.as_str()
890+
));
891+
} else {
892+
return Ok(String::new());
893+
}
894+
}
895+
out.push_str(&to_string(&val, false));
896+
i = end + 2;
897+
}
898+
}
899+
out.push_str(&t[i..]);
900+
Ok(out)
901+
}
902+
903+
let root_value = Value::from_map(vars_obj.as_ref().clone());
904+
let mut locals: BTreeMap<String, Value> = BTreeMap::new();
905+
let rendered = render_inner(
906+
template.as_ref(),
907+
&root_value,
908+
&mut locals,
909+
strict,
910+
params[0].span(),
911+
)?;
912+
Ok(Value::String(rendered.into()))
913+
}

src/builtins/time/compat.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ struct GoTimeFormatItems<'a> {
266266
mode: GoTimeFormatItemsMode,
267267
}
268268

269-
impl GoTimeFormatItems<'_> {
269+
impl<'a> GoTimeFormatItems<'a> {
270270
fn parse(reminder: &str) -> GoTimeFormatItems<'_> {
271271
GoTimeFormatItems {
272272
reminder,

src/interpreter.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3796,7 +3796,7 @@ impl Interpreter {
37963796
MapEntry::Occupied(o) => {
37973797
if idx + 1 == comps.len() {
37983798
for (_, i) in o.get() {
3799-
if let (Some(old), Some(new)) = (i, &index) {
3799+
if let (Some(old), Some(new)) = (i.as_ref(), index.as_ref()) {
38003800
if old == new {
38013801
bail!(refr.span().error("multiple default rules for the variable with the same index"));
38023802
}

0 commit comments

Comments
 (0)