1use crate::parser::Token;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum LowerTarget {
10 #[default]
12 Askama,
13 MiniJinja,
17}
18
19pub fn lower(tokens: &[Token]) -> String {
20 lower_with_target(tokens, LowerTarget::Askama)
21}
22
23pub fn lower_with_target(tokens: &[Token], target: LowerTarget) -> String {
24 let mut out = String::new();
25 let mut stack_pushes: Vec<String> = Vec::new();
26 let mut forelse_stack: Vec<String> = Vec::new(); let mut switch_started = false; for tok in tokens {
30 match tok {
31 Token::Text(s) => out.push_str(s),
32
33 Token::EscapedExpr(expr) => {
34 out.push_str("{{ ");
35 out.push_str(expr);
36 out.push_str(" }}");
37 }
38
39 Token::RawExpr(expr) => {
40 out.push_str("{{ ");
41 out.push_str(expr);
42 out.push_str("|safe }}");
43 }
44
45 Token::LiteralExpr(expr) => match target {
50 LowerTarget::Askama => {
51 out.push_str("{{ \"{{ ");
53 out.push_str(&expr.replace('"', "\\\""));
54 out.push_str(" }}\"|safe }}");
55 }
56 LowerTarget::MiniJinja => {
57 out.push_str("{% raw %}{{ ");
58 out.push_str(expr);
59 out.push_str(" }}{% endraw %}");
60 }
61 },
62
63 Token::LiteralDirective { name, args } => {
67 out.push('@');
68 out.push_str(name);
69 if let Some(a) = args {
70 out.push('(');
71 out.push_str(a);
72 out.push(')');
73 }
74 }
75
76 Token::Directive { name, args } => {
77 let args_inner = args.as_deref().unwrap_or("").trim();
78 let args_stripped_quotes = args_inner.trim_matches(|c| c == '"' || c == '\'');
79
80 match name.as_str() {
81 "if" => emit_if(&mut out, args_inner),
83 "elseif" => emit_elif(&mut out, args_inner),
84 "else" => out.push_str("{% else %}"),
85 "endif" => out.push_str("{% endif %}"),
86
87 "unless" => {
88 out.push_str("{% if !(");
89 out.push_str(args_inner);
90 out.push_str(") %}");
91 }
92 "endunless" => out.push_str("{% endif %}"),
93
94 "isset" => {
95 out.push_str("{% if (");
97 out.push_str(args_inner);
98 out.push_str(").is_some() %}");
99 }
100 "endisset" => out.push_str("{% endif %}"),
101
102 "empty" if !forelse_stack.is_empty() => {
103 out.push_str("{% endfor %}");
105 let _ = forelse_stack.pop();
106 out.push_str("{% if true %}");
107 forelse_stack.push("__empty_branch__".to_string());
109 }
110 "empty" => {
111 out.push_str("{% if (");
113 out.push_str(args_inner);
114 out.push_str(").is_empty() %}");
115 }
116 "endempty" => out.push_str("{% endif %}"),
117
118 "foreach" => {
120 let (lhs, rhs) = split_foreach(args_inner);
121 out.push_str("{% for ");
122 out.push_str(&rhs);
123 out.push_str(" in ");
124 out.push_str(&lhs);
125 out.push_str(" %}");
126 }
127 "endforeach" => out.push_str("{% endfor %}"),
128
129 "forelse" => {
130 let (lhs, rhs) = split_foreach(args_inner);
131 forelse_stack.push(lhs.clone());
132 out.push_str("{% for ");
133 out.push_str(&rhs);
134 out.push_str(" in ");
135 out.push_str(&lhs);
136 out.push_str(" %}");
137 }
138 "endforelse" => {
139 if let Some(top) = forelse_stack.pop() {
141 if top == "__empty_branch__" {
142 out.push_str("{% endif %}");
143 } else {
144 out.push_str("{% endfor %}");
145 }
146 }
147 }
148
149 "for" => {
150 out.push_str("{% for ");
151 out.push_str(args_inner);
152 out.push_str(" %}");
153 }
154 "endfor" => out.push_str("{% endfor %}"),
155
156 "continue" => {
157 if args_inner.is_empty() {
158 out.push_str("{% continue %}");
159 } else {
160 out.push_str("{% if ");
161 out.push_str(args_inner);
162 out.push_str(" %}{% continue %}{% endif %}");
163 }
164 }
165 "break" => {
166 if args_inner.is_empty() {
167 out.push_str("{% break %}");
168 } else {
169 out.push_str("{% if ");
170 out.push_str(args_inner);
171 out.push_str(" %}{% break %}{% endif %}");
172 }
173 }
174
175 "while" => {
176 out.push_str("{# @while not supported, use @foreach: ");
177 out.push_str(args_inner);
178 out.push_str(" #}");
179 }
180 "endwhile" => {}
181
182 "switch" => {
184 out.push_str("{# @switch(");
185 out.push_str(args_inner);
186 out.push_str(") #}");
187 switch_started = false;
188 out.push_str("<!--SWITCH:");
191 out.push_str(args_inner);
192 out.push_str("-->");
193 }
194 "case" => {
195 if !switch_started {
199 out.push_str("{% if __switch__ == ");
200 switch_started = true;
201 } else {
202 out.push_str("{% elif __switch__ == ");
203 }
204 out.push_str(args_inner);
205 out.push_str(" %}");
206 }
207 "default" => {
208 if switch_started {
209 out.push_str("{% else %}");
210 }
211 }
212 "endswitch" => {
213 if switch_started {
214 out.push_str("{% endif %}");
215 switch_started = false;
216 }
217 }
218
219 "extends" => {
221 out.push_str("{% extends \"");
222 out.push_str(&path_for(args_stripped_quotes));
223 out.push_str("\" %}");
224 }
225 "section" => {
226 if let Some((name, value)) = parse_inline_section(args_inner) {
228 out.push_str("{% block ");
229 out.push_str(&name);
230 out.push_str(" %}");
231 out.push_str(&value);
232 out.push_str("{% endblock %}");
233 } else {
234 out.push_str("{% block ");
235 out.push_str(args_stripped_quotes);
236 out.push_str(" %}");
237 }
238 }
239 "endsection" | "show" | "stop" | "overwrite" => out.push_str("{% endblock %}"),
240 "yield" => {
241 let (n, d) = parse_yield_args(args_inner);
244 out.push_str("{% block ");
245 out.push_str(&n);
246 out.push_str(" %}");
247 out.push_str(&d);
248 out.push_str("{% endblock %}");
249 }
250 "parent" => out.push_str("{{ super() }}"),
251 "include" => {
252 out.push_str("{% include \"");
253 out.push_str(&path_for(args_stripped_quotes));
254 out.push_str("\" %}");
255 }
256 "includeIf" | "includeif" | "includeWhen" | "includewhen" => {
257 out.push_str("{% include \"");
258 out.push_str(&path_for(args_stripped_quotes));
259 out.push_str("\" %}");
260 }
261 "hasSection" | "hassection" => {
262 out.push_str("{% if true %}{# @hasSection placeholder for '");
263 out.push_str(args_stripped_quotes);
264 out.push_str("' #}");
265 }
266 "endhasSection" | "endhassection" | "sectionMissing" | "sectionmissing" => {
267 out.push_str("{% endif %}");
268 }
269
270 "stack" => {
272 out.push_str("<!--FORGE-STACK:");
273 out.push_str(args_stripped_quotes);
274 out.push_str("-->");
275 }
276 "push" => {
277 stack_pushes.push(args_stripped_quotes.to_string());
278 out.push_str("<!--FORGE-PUSH-START:");
279 out.push_str(args_stripped_quotes);
280 out.push_str("-->");
281 }
282 "endpush" => {
283 let name = stack_pushes.pop().unwrap_or_default();
284 out.push_str("<!--FORGE-PUSH-END:");
285 out.push_str(&name);
286 out.push_str("-->");
287 }
288 "pushOnce" | "pushonce" => {
289 stack_pushes.push(args_stripped_quotes.to_string());
290 out.push_str("<!--FORGE-PUSHONCE-START:");
291 out.push_str(args_stripped_quotes);
292 out.push_str("-->");
293 }
294 "endPushOnce" | "endpushonce" => {
295 let name = stack_pushes.pop().unwrap_or_default();
296 out.push_str("<!--FORGE-PUSHONCE-END:");
297 out.push_str(&name);
298 out.push_str("-->");
299 }
300 "prepend" => {
301 stack_pushes.push(args_stripped_quotes.to_string());
302 out.push_str("<!--FORGE-PREPEND-START:");
303 out.push_str(args_stripped_quotes);
304 out.push_str("-->");
305 }
306 "endprepend" => {
307 let name = stack_pushes.pop().unwrap_or_default();
308 out.push_str("<!--FORGE-PREPEND-END:");
309 out.push_str(&name);
310 out.push_str("-->");
311 }
312 "once" => out.push_str("<!--FORGE-ONCE-START-->"),
313 "endonce" => out.push_str("<!--FORGE-ONCE-END-->"),
314
315 "vite" => match target {
318 LowerTarget::Askama => {
319 out.push_str("{{ ::forge::vite::render(&[");
320 out.push_str(args_inner);
321 out.push_str("])|safe }}");
322 }
323 LowerTarget::MiniJinja => {
324 out.push_str("{{ vite_render([");
331 out.push_str(args_inner);
332 out.push_str("])|safe }}");
333 }
334 },
335
336 "auth" => out.push_str("{% if auth_user.is_some() %}"),
338 "endauth" => out.push_str("{% endif %}"),
339 "guest" => out.push_str("{% if auth_user.is_none() %}"),
340 "endguest" => out.push_str("{% endif %}"),
341 "can" => {
342 out.push_str("{% if can(");
343 out.push_str(args_inner);
344 out.push_str(") %}");
345 }
346 "endcan" => out.push_str("{% endif %}"),
347 "cannot" => {
348 out.push_str("{% if !can(");
349 out.push_str(args_inner);
350 out.push_str(") %}");
351 }
352 "endcannot" => out.push_str("{% endif %}"),
353 "role" => {
354 out.push_str("{% if has_role(auth_user, ");
355 out.push_str(args_inner);
356 out.push_str(") %}");
357 }
358 "endrole" => out.push_str("{% endif %}"),
359
360 "checked" => emit_attr_helper(&mut out, "checked", args_inner),
362 "selected" => emit_attr_helper(&mut out, "selected", args_inner),
363 "disabled" => emit_attr_helper(&mut out, "disabled", args_inner),
364 "readonly" => emit_attr_helper(&mut out, "readonly", args_inner),
365 "required" => emit_attr_helper(&mut out, "required", args_inner),
366
367 "error" => {
369 out.push_str("{% if let Some(message) = errors.get(\"");
371 out.push_str(args_stripped_quotes);
372 out.push_str("\").and_then(|v| v.first()) %}");
373 }
374 "enderror" => out.push_str("{% endif %}"),
375 "old" => {
376 let (field, default) = parse_old_args(args_inner);
378 out.push_str("{{ ::forge::escape::html(&old_input.get(\"");
379 out.push_str(&field);
380 out.push_str("\").cloned().unwrap_or_else(|| ");
381 out.push_str(&default);
382 out.push_str(".to_string())) }}");
383 }
384
385 "class" => {
387 out.push_str("class=\"{{ ::forge::escape::html(&::forge::class_list(&[");
389 out.push_str(args_inner);
390 out.push_str("])) }}\"");
391 }
392 "style" => {
393 out.push_str("style=\"{{ ::forge::escape::html(&::forge::style_list(&[");
394 out.push_str(args_inner);
395 out.push_str("])) }}\"");
396 }
397
398 "dump" | "dd" => {
400 out.push_str("<pre>{{ format!(\"{:#?}\", ");
401 out.push_str(args_inner);
402 out.push_str(")|escape }}</pre>");
403 }
404 "json" => {
405 out.push_str("{{ ::serde_json::to_string(&");
407 out.push_str(args_inner);
408 out.push_str(").unwrap_or_default()|safe }}");
409 }
410
411 "verbatim" | "endverbatim" => {}
413
414 "csrf" => {
416 out.push_str(
417 "<input type=\"hidden\" name=\"_token\" value=\"{{ csrf_token }}\">",
418 );
419 }
420 "method" => {
421 out.push_str("<input type=\"hidden\" name=\"_method\" value=\"");
422 out.push_str(args_stripped_quotes);
423 out.push_str("\">");
424 }
425
426 "lang" | "trans" => {
428 out.push_str("{{ ::forge::escape::html(&::forge::lang(");
429 out.push_str(args_inner);
430 out.push_str(")) }}");
431 }
432 "choice" => {
433 out.push_str("{{ ::forge::escape::html(&::forge::lang_choice(");
434 out.push_str(args_inner);
435 out.push_str(")) }}");
436 }
437
438 "props" => {
440 out.push_str("{# props: ");
443 out.push_str(args_inner);
444 out.push_str(" #}");
445 }
446
447 "spark" => match target {
450 LowerTarget::Askama => {
451 let (name, props_expr) = parse_spark_args(args_inner);
452 out.push_str("{{ ::spark::render::render_mount(\"");
453 out.push_str(&name.replace('"', "\\\""));
454 out.push_str("\", &::spark::serde_json::json!(");
455 if props_expr.is_empty() {
456 out.push_str("null");
457 } else {
458 out.push_str(&props_expr);
459 }
460 out.push_str(")).unwrap_or_default()|safe }}");
461 }
462 LowerTarget::MiniJinja => {
463 let (name, props_expr) = parse_spark_args(args_inner);
468 out.push_str("{{ spark_mount(\"");
469 out.push_str(&name.replace('"', "\\\""));
470 if props_expr.is_empty() {
471 out.push_str("\")|safe }}");
472 } else {
473 out.push_str("\", ");
474 out.push_str("e_dict_keys(&props_expr));
475 out.push_str(")|safe }}");
476 }
477 }
478 },
479
480 "sparkScripts" | "sparkscripts" => match target {
482 LowerTarget::Askama => {
483 out.push_str("{{ ::spark::render::boot_script()|safe }}");
484 }
485 LowerTarget::MiniJinja => {
486 out.push_str("{{ spark_scripts()|safe }}");
487 }
488 },
489
490 "sparkIsland" | "sparkisland" => {
492 let name = args_stripped_quotes;
493 out.push_str("<div spark:island=\"");
494 out.push_str(name);
495 out.push_str("\">");
496 }
497 "endSparkIsland" | "endsparkisland" => {
498 out.push_str("</div>");
499 }
500
501 _ => {
503 out.push_str("{# unknown directive @");
504 out.push_str(name);
505 if !args_inner.is_empty() {
506 out.push('(');
507 out.push_str(args_inner);
508 out.push(')');
509 }
510 out.push_str(" #}");
511 }
512 }
513 }
514
515 Token::ComponentOpen {
516 name,
517 attrs,
518 self_closing,
519 } => {
520 let component_macro = component_macro_name(name);
522 out.push_str("{% call ");
523 out.push_str(&component_macro);
524 out.push('(');
525 let mut first = true;
526 for (k, v) in attrs {
527 if !first {
528 out.push_str(", ");
529 }
530 first = false;
531 out.push_str(k);
532 out.push_str("=\"");
533 out.push_str(v);
534 out.push('"');
535 }
536 out.push_str(") %}");
537 if *self_closing {
538 out.push_str("{% endcall %}");
539 }
540 }
541
542 Token::ComponentClose { .. } => {
543 out.push_str("{% endcall %}");
544 }
545 }
546 }
547
548 out
549}
550
551fn emit_if(out: &mut String, expr: &str) {
554 out.push_str("{% if ");
555 out.push_str(expr);
556 out.push_str(" %}");
557}
558
559fn emit_elif(out: &mut String, expr: &str) {
560 out.push_str("{% elif ");
561 out.push_str(expr);
562 out.push_str(" %}");
563}
564
565fn emit_attr_helper(out: &mut String, attr: &str, condition: &str) {
566 out.push_str("{% if (");
567 out.push_str(condition);
568 out.push_str(") %}");
569 out.push_str(attr);
570 out.push_str("{% endif %}");
571}
572
573fn parse_inline_section(args: &str) -> Option<(String, String)> {
574 let args = args.trim();
576 if let Some(comma) = find_top_level_comma(args) {
577 let name = args[..comma]
578 .trim()
579 .trim_matches(|c| c == '"' || c == '\'')
580 .to_string();
581 let value = args[comma + 1..].trim().to_string();
582 if !name.is_empty() {
583 return Some((name, value));
584 }
585 }
586 None
587}
588
589fn parse_yield_args(args: &str) -> (String, String) {
590 if let Some(comma) = find_top_level_comma(args) {
591 let n = args[..comma]
592 .trim()
593 .trim_matches(|c| c == '"' || c == '\'')
594 .to_string();
595 let d_raw = args[comma + 1..].trim();
596 let d = if d_raw.starts_with('"') || d_raw.starts_with('\'') {
597 d_raw.trim_matches(|c| c == '"' || c == '\'').to_string()
599 } else {
600 format!("{{{{ {d_raw} }}}}")
602 };
603 return (n, d);
604 }
605 (
606 args.trim()
607 .trim_matches(|c| c == '"' || c == '\'')
608 .to_string(),
609 String::new(),
610 )
611}
612
613fn parse_old_args(args: &str) -> (String, String) {
614 if let Some(comma) = find_top_level_comma(args) {
615 let f = args[..comma]
616 .trim()
617 .trim_matches(|c| c == '"' || c == '\'')
618 .to_string();
619 let d = args[comma + 1..].trim().to_string();
620 return (f, d);
621 }
622 (
623 args.trim()
624 .trim_matches(|c| c == '"' || c == '\'')
625 .to_string(),
626 "\"\"".to_string(),
627 )
628}
629
630fn quote_dict_keys(input: &str) -> String {
637 let bytes = input.as_bytes();
638 let mut out = String::with_capacity(input.len() + 16);
639 let mut i = 0;
640 let mut in_str: Option<u8> = None;
641
642 while i < bytes.len() {
643 let b = bytes[i];
644
645 if let Some(q) = in_str {
647 out.push(b as char);
648 if b == q && bytes.get(i.saturating_sub(1)) != Some(&b'\\') {
649 in_str = None;
650 }
651 i += 1;
652 continue;
653 }
654 if b == b'"' || b == b'\'' {
655 in_str = Some(b);
656 out.push(b as char);
657 i += 1;
658 continue;
659 }
660
661 if b == b'{' || b == b',' {
663 out.push(b as char);
664 i += 1;
665 while i < bytes.len() && (bytes[i] as char).is_whitespace() {
667 out.push(bytes[i] as char);
668 i += 1;
669 }
670 if i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
672 let start = i;
673 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
674 i += 1;
675 }
676 let ident = &input[start..i];
677 let mut j = i;
679 while j < bytes.len() && (bytes[j] as char).is_whitespace() {
680 j += 1;
681 }
682 if j < bytes.len() && bytes[j] == b':' {
683 out.push('"');
684 out.push_str(ident);
685 out.push('"');
686 out.push_str(&input[i..j]);
690 out.push(':');
691 i = j + 1;
692 continue;
693 } else {
694 out.push_str(ident);
696 continue;
697 }
698 }
699 continue;
700 }
701
702 out.push(b as char);
703 i += 1;
704 }
705 out
706}
707
708fn parse_spark_args(args: &str) -> (String, String) {
715 let args = args.trim();
716 if args.is_empty() {
717 return (String::new(), String::new());
718 }
719 if let Some(comma) = find_top_level_comma(args) {
720 let name = args[..comma]
721 .trim()
722 .trim_matches(|c| c == '"' || c == '\'')
723 .to_string();
724 let props = args[comma + 1..].trim().to_string();
725 return (name, props);
726 }
727 (
728 args.trim_matches(|c: char| c == '"' || c == '\'')
729 .to_string(),
730 String::new(),
731 )
732}
733
734fn find_top_level_comma(s: &str) -> Option<usize> {
735 let mut depth_paren = 0;
736 let mut depth_bracket = 0;
737 let mut in_string = None::<char>;
738 for (i, ch) in s.char_indices() {
739 if let Some(q) = in_string {
740 if ch == q {
741 in_string = None;
742 }
743 continue;
744 }
745 match ch {
746 '"' | '\'' => in_string = Some(ch),
747 '(' => depth_paren += 1,
748 ')' => depth_paren -= 1,
749 '[' => depth_bracket += 1,
750 ']' => depth_bracket -= 1,
751 ',' if depth_paren == 0 && depth_bracket == 0 => return Some(i),
752 _ => {}
753 }
754 }
755 None
756}
757
758fn path_for(spec: &str) -> String {
759 let s = spec.replace('.', "/");
760 if s.ends_with(".html") {
761 s
762 } else {
763 format!("{s}.html")
764 }
765}
766
767fn component_macro_name(name: &str) -> String {
768 name.replace('-', "_")
769}
770
771fn split_foreach(args: &str) -> (String, String) {
772 let args = args.trim();
773 if let Some((lhs, rhs)) = args.split_once(" as ") {
774 let lhs = lhs.trim().trim_start_matches('$').to_string();
775 let rhs = rhs.trim().trim_start_matches('$').to_string();
776 if let Some((_k, v)) = rhs.split_once("=>") {
777 return (lhs, v.trim().trim_start_matches('$').to_string());
778 }
779 (lhs, rhs)
780 } else {
781 (args.to_string(), "item".to_string())
782 }
783}