@@ -12,6 +12,7 @@ use crate::number::Number;
1212use crate :: value:: Value ;
1313use crate :: * ;
1414
15+ use alloc:: collections:: BTreeMap ;
1516use anyhow:: { bail, Result } ;
1617
1718pub 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+ }
0 commit comments