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::Directive { name, args } => {
46 let args_inner = args.as_deref().unwrap_or("").trim();
47 let args_stripped_quotes = args_inner.trim_matches(|c| c == '"' || c == '\'');
48
49 match name.as_str() {
50 "if" => emit_if(&mut out, args_inner),
52 "elseif" => emit_elif(&mut out, args_inner),
53 "else" => out.push_str("{% else %}"),
54 "endif" => out.push_str("{% endif %}"),
55
56 "unless" => {
57 out.push_str("{% if !(");
58 out.push_str(args_inner);
59 out.push_str(") %}");
60 }
61 "endunless" => out.push_str("{% endif %}"),
62
63 "isset" => {
64 out.push_str("{% if (");
66 out.push_str(args_inner);
67 out.push_str(").is_some() %}");
68 }
69 "endisset" => out.push_str("{% endif %}"),
70
71 "empty" if !forelse_stack.is_empty() => {
72 out.push_str("{% endfor %}");
74 let _ = forelse_stack.pop();
75 out.push_str("{% if true %}");
76 forelse_stack.push("__empty_branch__".to_string());
78 }
79 "empty" => {
80 out.push_str("{% if (");
82 out.push_str(args_inner);
83 out.push_str(").is_empty() %}");
84 }
85 "endempty" => out.push_str("{% endif %}"),
86
87 "foreach" => {
89 let (lhs, rhs) = split_foreach(args_inner);
90 out.push_str("{% for ");
91 out.push_str(&rhs);
92 out.push_str(" in ");
93 out.push_str(&lhs);
94 out.push_str(" %}");
95 }
96 "endforeach" => out.push_str("{% endfor %}"),
97
98 "forelse" => {
99 let (lhs, rhs) = split_foreach(args_inner);
100 forelse_stack.push(lhs.clone());
101 out.push_str("{% for ");
102 out.push_str(&rhs);
103 out.push_str(" in ");
104 out.push_str(&lhs);
105 out.push_str(" %}");
106 }
107 "endforelse" => {
108 if let Some(top) = forelse_stack.pop() {
110 if top == "__empty_branch__" {
111 out.push_str("{% endif %}");
112 } else {
113 out.push_str("{% endfor %}");
114 }
115 }
116 }
117
118 "for" => {
119 out.push_str("{% for ");
120 out.push_str(args_inner);
121 out.push_str(" %}");
122 }
123 "endfor" => out.push_str("{% endfor %}"),
124
125 "continue" => {
126 if args_inner.is_empty() {
127 out.push_str("{% continue %}");
128 } else {
129 out.push_str("{% if ");
130 out.push_str(args_inner);
131 out.push_str(" %}{% continue %}{% endif %}");
132 }
133 }
134 "break" => {
135 if args_inner.is_empty() {
136 out.push_str("{% break %}");
137 } else {
138 out.push_str("{% if ");
139 out.push_str(args_inner);
140 out.push_str(" %}{% break %}{% endif %}");
141 }
142 }
143
144 "while" => {
145 out.push_str("{# @while not supported, use @foreach: ");
146 out.push_str(args_inner);
147 out.push_str(" #}");
148 }
149 "endwhile" => {}
150
151 "switch" => {
153 out.push_str("{# @switch(");
154 out.push_str(args_inner);
155 out.push_str(") #}");
156 switch_started = false;
157 out.push_str("<!--SWITCH:");
160 out.push_str(args_inner);
161 out.push_str("-->");
162 }
163 "case" => {
164 if !switch_started {
168 out.push_str("{% if __switch__ == ");
169 switch_started = true;
170 } else {
171 out.push_str("{% elif __switch__ == ");
172 }
173 out.push_str(args_inner);
174 out.push_str(" %}");
175 }
176 "default" => {
177 if switch_started {
178 out.push_str("{% else %}");
179 }
180 }
181 "endswitch" => {
182 if switch_started {
183 out.push_str("{% endif %}");
184 switch_started = false;
185 }
186 }
187
188 "extends" => {
190 out.push_str("{% extends \"");
191 out.push_str(&path_for(args_stripped_quotes));
192 out.push_str("\" %}");
193 }
194 "section" => {
195 if let Some((name, value)) = parse_inline_section(args_inner) {
197 out.push_str("{% block ");
198 out.push_str(&name);
199 out.push_str(" %}");
200 out.push_str(&value);
201 out.push_str("{% endblock %}");
202 } else {
203 out.push_str("{% block ");
204 out.push_str(args_stripped_quotes);
205 out.push_str(" %}");
206 }
207 }
208 "endsection" | "show" | "stop" | "overwrite" => out.push_str("{% endblock %}"),
209 "yield" => {
210 let (n, d) = parse_yield_args(args_inner);
213 out.push_str("{% block ");
214 out.push_str(&n);
215 out.push_str(" %}");
216 out.push_str(&d);
217 out.push_str("{% endblock %}");
218 }
219 "parent" => out.push_str("{{ super() }}"),
220 "include" => {
221 out.push_str("{% include \"");
222 out.push_str(&path_for(args_stripped_quotes));
223 out.push_str("\" %}");
224 }
225 "includeIf" | "includeif" | "includeWhen" | "includewhen" => {
226 out.push_str("{% include \"");
227 out.push_str(&path_for(args_stripped_quotes));
228 out.push_str("\" %}");
229 }
230 "hasSection" | "hassection" => {
231 out.push_str("{% if true %}{# @hasSection placeholder for '");
232 out.push_str(args_stripped_quotes);
233 out.push_str("' #}");
234 }
235 "endhasSection" | "endhassection" | "sectionMissing" | "sectionmissing" => {
236 out.push_str("{% endif %}");
237 }
238
239 "stack" => {
241 out.push_str("<!--FORGE-STACK:");
242 out.push_str(args_stripped_quotes);
243 out.push_str("-->");
244 }
245 "push" => {
246 stack_pushes.push(args_stripped_quotes.to_string());
247 out.push_str("<!--FORGE-PUSH-START:");
248 out.push_str(args_stripped_quotes);
249 out.push_str("-->");
250 }
251 "endpush" => {
252 let name = stack_pushes.pop().unwrap_or_default();
253 out.push_str("<!--FORGE-PUSH-END:");
254 out.push_str(&name);
255 out.push_str("-->");
256 }
257 "pushOnce" | "pushonce" => {
258 stack_pushes.push(args_stripped_quotes.to_string());
259 out.push_str("<!--FORGE-PUSHONCE-START:");
260 out.push_str(args_stripped_quotes);
261 out.push_str("-->");
262 }
263 "endPushOnce" | "endpushonce" => {
264 let name = stack_pushes.pop().unwrap_or_default();
265 out.push_str("<!--FORGE-PUSHONCE-END:");
266 out.push_str(&name);
267 out.push_str("-->");
268 }
269 "prepend" => {
270 stack_pushes.push(args_stripped_quotes.to_string());
271 out.push_str("<!--FORGE-PREPEND-START:");
272 out.push_str(args_stripped_quotes);
273 out.push_str("-->");
274 }
275 "endprepend" => {
276 let name = stack_pushes.pop().unwrap_or_default();
277 out.push_str("<!--FORGE-PREPEND-END:");
278 out.push_str(&name);
279 out.push_str("-->");
280 }
281 "once" => out.push_str("<!--FORGE-ONCE-START-->"),
282 "endonce" => out.push_str("<!--FORGE-ONCE-END-->"),
283
284 "vite" => match target {
287 LowerTarget::Askama => {
288 out.push_str("{{ ::forge::vite::render(&[");
289 out.push_str(args_inner);
290 out.push_str("])|safe }}");
291 }
292 LowerTarget::MiniJinja => {
293 out.push_str("{{ vite_render([");
300 out.push_str(args_inner);
301 out.push_str("])|safe }}");
302 }
303 },
304
305 "auth" => out.push_str("{% if auth_user.is_some() %}"),
307 "endauth" => out.push_str("{% endif %}"),
308 "guest" => out.push_str("{% if auth_user.is_none() %}"),
309 "endguest" => out.push_str("{% endif %}"),
310 "can" => {
311 out.push_str("{% if can(");
312 out.push_str(args_inner);
313 out.push_str(") %}");
314 }
315 "endcan" => out.push_str("{% endif %}"),
316 "cannot" => {
317 out.push_str("{% if !can(");
318 out.push_str(args_inner);
319 out.push_str(") %}");
320 }
321 "endcannot" => out.push_str("{% endif %}"),
322 "role" => {
323 out.push_str("{% if has_role(auth_user, ");
324 out.push_str(args_inner);
325 out.push_str(") %}");
326 }
327 "endrole" => out.push_str("{% endif %}"),
328
329 "checked" => emit_attr_helper(&mut out, "checked", args_inner),
331 "selected" => emit_attr_helper(&mut out, "selected", args_inner),
332 "disabled" => emit_attr_helper(&mut out, "disabled", args_inner),
333 "readonly" => emit_attr_helper(&mut out, "readonly", args_inner),
334 "required" => emit_attr_helper(&mut out, "required", args_inner),
335
336 "error" => {
338 out.push_str("{% if let Some(message) = errors.get(\"");
340 out.push_str(args_stripped_quotes);
341 out.push_str("\").and_then(|v| v.first()) %}");
342 }
343 "enderror" => out.push_str("{% endif %}"),
344 "old" => {
345 let (field, default) = parse_old_args(args_inner);
347 out.push_str("{{ ::forge::escape::html(&old_input.get(\"");
348 out.push_str(&field);
349 out.push_str("\").cloned().unwrap_or_else(|| ");
350 out.push_str(&default);
351 out.push_str(".to_string())) }}");
352 }
353
354 "class" => {
356 out.push_str("class=\"{{ ::forge::escape::html(&::forge::class_list(&[");
358 out.push_str(args_inner);
359 out.push_str("])) }}\"");
360 }
361 "style" => {
362 out.push_str("style=\"{{ ::forge::escape::html(&::forge::style_list(&[");
363 out.push_str(args_inner);
364 out.push_str("])) }}\"");
365 }
366
367 "dump" | "dd" => {
369 out.push_str("<pre>{{ format!(\"{:#?}\", ");
370 out.push_str(args_inner);
371 out.push_str(")|escape }}</pre>");
372 }
373 "json" => {
374 out.push_str("{{ ::serde_json::to_string(&");
376 out.push_str(args_inner);
377 out.push_str(").unwrap_or_default()|safe }}");
378 }
379
380 "verbatim" | "endverbatim" => {}
382
383 "csrf" => {
385 out.push_str(
386 "<input type=\"hidden\" name=\"_token\" value=\"{{ csrf_token }}\">",
387 );
388 }
389 "method" => {
390 out.push_str("<input type=\"hidden\" name=\"_method\" value=\"");
391 out.push_str(args_stripped_quotes);
392 out.push_str("\">");
393 }
394
395 "lang" | "trans" => {
397 out.push_str("{{ ::forge::escape::html(&::forge::lang(");
398 out.push_str(args_inner);
399 out.push_str(")) }}");
400 }
401 "choice" => {
402 out.push_str("{{ ::forge::escape::html(&::forge::lang_choice(");
403 out.push_str(args_inner);
404 out.push_str(")) }}");
405 }
406
407 "props" => {
409 out.push_str("{# props: ");
412 out.push_str(args_inner);
413 out.push_str(" #}");
414 }
415
416 "spark" => match target {
419 LowerTarget::Askama => {
420 let (name, props_expr) = parse_spark_args(args_inner);
421 out.push_str("{{ ::spark::render::render_mount(\"");
422 out.push_str(&name.replace('"', "\\\""));
423 out.push_str("\", &::spark::serde_json::json!(");
424 if props_expr.is_empty() {
425 out.push_str("null");
426 } else {
427 out.push_str(&props_expr);
428 }
429 out.push_str(")).unwrap_or_default()|safe }}");
430 }
431 LowerTarget::MiniJinja => {
432 let (name, props_expr) = parse_spark_args(args_inner);
437 out.push_str("{{ spark_mount(\"");
438 out.push_str(&name.replace('"', "\\\""));
439 if props_expr.is_empty() {
440 out.push_str("\")|safe }}");
441 } else {
442 out.push_str("\", ");
443 out.push_str("e_dict_keys(&props_expr));
444 out.push_str(")|safe }}");
445 }
446 }
447 },
448
449 "sparkScripts" | "sparkscripts" => match target {
451 LowerTarget::Askama => {
452 out.push_str("{{ ::spark::render::boot_script()|safe }}");
453 }
454 LowerTarget::MiniJinja => {
455 out.push_str("{{ spark_scripts()|safe }}");
456 }
457 },
458
459 "sparkIsland" | "sparkisland" => {
461 let name = args_stripped_quotes;
462 out.push_str("<div spark:island=\"");
463 out.push_str(name);
464 out.push_str("\">");
465 }
466 "endSparkIsland" | "endsparkisland" => {
467 out.push_str("</div>");
468 }
469
470 _ => {
472 out.push_str("{# unknown directive @");
473 out.push_str(name);
474 if !args_inner.is_empty() {
475 out.push('(');
476 out.push_str(args_inner);
477 out.push(')');
478 }
479 out.push_str(" #}");
480 }
481 }
482 }
483
484 Token::ComponentOpen {
485 name,
486 attrs,
487 self_closing,
488 } => {
489 let component_macro = component_macro_name(name);
491 out.push_str("{% call ");
492 out.push_str(&component_macro);
493 out.push('(');
494 let mut first = true;
495 for (k, v) in attrs {
496 if !first {
497 out.push_str(", ");
498 }
499 first = false;
500 out.push_str(k);
501 out.push_str("=\"");
502 out.push_str(v);
503 out.push('"');
504 }
505 out.push_str(") %}");
506 if *self_closing {
507 out.push_str("{% endcall %}");
508 }
509 }
510
511 Token::ComponentClose { .. } => {
512 out.push_str("{% endcall %}");
513 }
514 }
515 }
516
517 out
518}
519
520fn emit_if(out: &mut String, expr: &str) {
523 out.push_str("{% if ");
524 out.push_str(expr);
525 out.push_str(" %}");
526}
527
528fn emit_elif(out: &mut String, expr: &str) {
529 out.push_str("{% elif ");
530 out.push_str(expr);
531 out.push_str(" %}");
532}
533
534fn emit_attr_helper(out: &mut String, attr: &str, condition: &str) {
535 out.push_str("{% if (");
536 out.push_str(condition);
537 out.push_str(") %}");
538 out.push_str(attr);
539 out.push_str("{% endif %}");
540}
541
542fn parse_inline_section(args: &str) -> Option<(String, String)> {
543 let args = args.trim();
545 if let Some(comma) = find_top_level_comma(args) {
546 let name = args[..comma]
547 .trim()
548 .trim_matches(|c| c == '"' || c == '\'')
549 .to_string();
550 let value = args[comma + 1..].trim().to_string();
551 if !name.is_empty() {
552 return Some((name, value));
553 }
554 }
555 None
556}
557
558fn parse_yield_args(args: &str) -> (String, String) {
559 if let Some(comma) = find_top_level_comma(args) {
560 let n = args[..comma]
561 .trim()
562 .trim_matches(|c| c == '"' || c == '\'')
563 .to_string();
564 let d_raw = args[comma + 1..].trim();
565 let d = if d_raw.starts_with('"') || d_raw.starts_with('\'') {
566 d_raw.trim_matches(|c| c == '"' || c == '\'').to_string()
568 } else {
569 format!("{{{{ {d_raw} }}}}")
571 };
572 return (n, d);
573 }
574 (
575 args.trim()
576 .trim_matches(|c| c == '"' || c == '\'')
577 .to_string(),
578 String::new(),
579 )
580}
581
582fn parse_old_args(args: &str) -> (String, String) {
583 if let Some(comma) = find_top_level_comma(args) {
584 let f = args[..comma]
585 .trim()
586 .trim_matches(|c| c == '"' || c == '\'')
587 .to_string();
588 let d = args[comma + 1..].trim().to_string();
589 return (f, d);
590 }
591 (
592 args.trim()
593 .trim_matches(|c| c == '"' || c == '\'')
594 .to_string(),
595 "\"\"".to_string(),
596 )
597}
598
599fn quote_dict_keys(input: &str) -> String {
606 let bytes = input.as_bytes();
607 let mut out = String::with_capacity(input.len() + 16);
608 let mut i = 0;
609 let mut in_str: Option<u8> = None;
610
611 while i < bytes.len() {
612 let b = bytes[i];
613
614 if let Some(q) = in_str {
616 out.push(b as char);
617 if b == q && bytes.get(i.saturating_sub(1)) != Some(&b'\\') {
618 in_str = None;
619 }
620 i += 1;
621 continue;
622 }
623 if b == b'"' || b == b'\'' {
624 in_str = Some(b);
625 out.push(b as char);
626 i += 1;
627 continue;
628 }
629
630 if b == b'{' || b == b',' {
632 out.push(b as char);
633 i += 1;
634 while i < bytes.len() && (bytes[i] as char).is_whitespace() {
636 out.push(bytes[i] as char);
637 i += 1;
638 }
639 if i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
641 let start = i;
642 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
643 i += 1;
644 }
645 let ident = &input[start..i];
646 let mut j = i;
648 while j < bytes.len() && (bytes[j] as char).is_whitespace() {
649 j += 1;
650 }
651 if j < bytes.len() && bytes[j] == b':' {
652 out.push('"');
653 out.push_str(ident);
654 out.push('"');
655 out.push_str(&input[i..j]);
659 out.push(':');
660 i = j + 1;
661 continue;
662 } else {
663 out.push_str(ident);
665 continue;
666 }
667 }
668 continue;
669 }
670
671 out.push(b as char);
672 i += 1;
673 }
674 out
675}
676
677fn parse_spark_args(args: &str) -> (String, String) {
684 let args = args.trim();
685 if args.is_empty() {
686 return (String::new(), String::new());
687 }
688 if let Some(comma) = find_top_level_comma(args) {
689 let name = args[..comma]
690 .trim()
691 .trim_matches(|c| c == '"' || c == '\'')
692 .to_string();
693 let props = args[comma + 1..].trim().to_string();
694 return (name, props);
695 }
696 (
697 args.trim_matches(|c: char| c == '"' || c == '\'')
698 .to_string(),
699 String::new(),
700 )
701}
702
703fn find_top_level_comma(s: &str) -> Option<usize> {
704 let mut depth_paren = 0;
705 let mut depth_bracket = 0;
706 let mut in_string = None::<char>;
707 for (i, ch) in s.char_indices() {
708 if let Some(q) = in_string {
709 if ch == q {
710 in_string = None;
711 }
712 continue;
713 }
714 match ch {
715 '"' | '\'' => in_string = Some(ch),
716 '(' => depth_paren += 1,
717 ')' => depth_paren -= 1,
718 '[' => depth_bracket += 1,
719 ']' => depth_bracket -= 1,
720 ',' if depth_paren == 0 && depth_bracket == 0 => return Some(i),
721 _ => {}
722 }
723 }
724 None
725}
726
727fn path_for(spec: &str) -> String {
728 let s = spec.replace('.', "/");
729 if s.ends_with(".html") {
730 s
731 } else {
732 format!("{s}.html")
733 }
734}
735
736fn component_macro_name(name: &str) -> String {
737 name.replace('-', "_")
738}
739
740fn split_foreach(args: &str) -> (String, String) {
741 let args = args.trim();
742 if let Some((lhs, rhs)) = args.split_once(" as ") {
743 let lhs = lhs.trim().trim_start_matches('$').to_string();
744 let rhs = rhs.trim().trim_start_matches('$').to_string();
745 if let Some((_k, v)) = rhs.split_once("=>") {
746 return (lhs, v.trim().trim_start_matches('$').to_string());
747 }
748 (lhs, rhs)
749 } else {
750 (args.to_string(), "item".to_string())
751 }
752}