Skip to content

Commit 2a36a36

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

File tree

3 files changed

+271
-2
lines changed

3 files changed

+271
-2
lines changed

src/builtins/strings.rs

Lines changed: 269 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,270 @@ 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) = {
790+
let (b_start, b_end_after) = find_block_end(t, end + 2, err_span)?;
791+
(b_start, b_end_after)
792+
};
793+
let body = &t[end + 2..body_start];
794+
795+
// Prepare variable names and capture prior values to restore after the range block
796+
let var0_name = names[0].trim_start_matches('$').to_string();
797+
let prev_var0 = locals.get(&var0_name).cloned();
798+
let var1_name_opt = if names.len() > 1 {
799+
Some(names[1].trim_start_matches('$').to_string())
800+
} else {
801+
None
802+
};
803+
let prev_var1 = var1_name_opt.as_ref().and_then(|n| locals.get(n).cloned());
804+
805+
// Evaluate iterable
806+
let root_obj = root;
807+
let iter_val = eval_expr(expr_part, root_obj, locals);
808+
match iter_val {
809+
Value::Array(arr) => {
810+
for (idx, item) in arr.iter().enumerate() {
811+
// Assign loop variables
812+
if names.len() == 1 {
813+
locals.insert(var0_name.clone(), item.clone());
814+
} else {
815+
locals.insert(var0_name.clone(), Value::from(idx as u64));
816+
if let Some(var1_name) = &var1_name_opt {
817+
locals.insert(var1_name.clone(), item.clone());
818+
}
819+
}
820+
out.push_str(&render_inner(body, root_obj, locals, strict, err_span)?);
821+
}
822+
}
823+
Value::Object(map) => {
824+
for (k, v) in map.iter() {
825+
if names.len() == 1 {
826+
locals.insert(var0_name.clone(), v.clone());
827+
} else {
828+
locals.insert(var0_name.clone(), k.clone());
829+
if let Some(var1_name) = &var1_name_opt {
830+
locals.insert(var1_name.clone(), v.clone());
831+
}
832+
}
833+
out.push_str(&render_inner(body, root_obj, locals, strict, err_span)?);
834+
}
835+
}
836+
Value::Set(set) => {
837+
for (idx, v) in set.iter().enumerate() {
838+
if names.len() == 1 {
839+
locals.insert(var0_name.clone(), v.clone());
840+
} else {
841+
locals.insert(var0_name.clone(), Value::from(idx as u64));
842+
if let Some(var1_name) = &var1_name_opt {
843+
locals.insert(var1_name.clone(), v.clone());
844+
}
845+
}
846+
out.push_str(&render_inner(body, root_obj, locals, strict, err_span)?);
847+
}
848+
}
849+
Value::Undefined | Value::Null => { /* no iterations */ }
850+
_ => {
851+
if strict {
852+
bail!(err_span.error("`range` expects array, set, or object"));
853+
} else {
854+
return Ok(String::new());
855+
}
856+
}
857+
}
858+
// Restore variable scope after finishing the range block
859+
if let Some(var1_name) = var1_name_opt {
860+
if let Some(prev) = prev_var1 {
861+
locals.insert(var1_name, prev);
862+
} else {
863+
locals.remove(&var1_name);
864+
}
865+
}
866+
if let Some(prev) = prev_var0 {
867+
locals.insert(var0_name, prev);
868+
} else {
869+
locals.remove(&var0_name);
870+
}
871+
872+
i = block_end_after; // continue after {{end}}
873+
continue;
874+
} else if let Some(rest) = action.strip_prefix("if ") {
875+
let (body_start, block_end_after) = {
876+
let (b_start, b_end_after) = find_block_end(t, end + 2, err_span)?;
877+
(b_start, b_end_after)
878+
};
879+
let cond = eval_expr(rest, &root.clone(), locals);
880+
if is_truthy(&cond) {
881+
let body = &t[end + 2..body_start];
882+
out.push_str(&render_inner(
883+
body,
884+
&root.clone(),
885+
locals,
886+
strict,
887+
err_span,
888+
)?);
889+
}
890+
i = block_end_after;
891+
continue;
892+
} else {
893+
// Interpolation: $var or .path
894+
let val = eval_expr(action, &root.clone(), locals);
895+
if val == Value::Undefined {
896+
if strict {
897+
bail!(err_span.error(
898+
format!(
899+
"`strings.render_template` missing value for key `{}`",
900+
action
901+
)
902+
.as_str()
903+
));
904+
} else {
905+
return Ok(String::new());
906+
}
907+
}
908+
out.push_str(&to_string(&val, false));
909+
i = end + 2;
910+
}
911+
}
912+
out.push_str(&t[i..]);
913+
Ok(out)
914+
}
915+
916+
let root_value = Value::from_map(vars_obj.as_ref().clone());
917+
let mut locals: BTreeMap<String, Value> = BTreeMap::new();
918+
let rendered = render_inner(
919+
template.as_ref(),
920+
&root_value,
921+
&mut locals,
922+
strict,
923+
params[0].span(),
924+
)?;
925+
Ok(Value::String(rendered.into()))
926+
}

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)