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" => {
286 out.push_str("{{ ::forge::vite::render(&[");
287 out.push_str(args_inner);
288 out.push_str("])|safe }}");
289 }
290
291 "auth" => out.push_str("{% if auth_user.is_some() %}"),
293 "endauth" => out.push_str("{% endif %}"),
294 "guest" => out.push_str("{% if auth_user.is_none() %}"),
295 "endguest" => out.push_str("{% endif %}"),
296 "can" => {
297 out.push_str("{% if can(");
298 out.push_str(args_inner);
299 out.push_str(") %}");
300 }
301 "endcan" => out.push_str("{% endif %}"),
302 "cannot" => {
303 out.push_str("{% if !can(");
304 out.push_str(args_inner);
305 out.push_str(") %}");
306 }
307 "endcannot" => out.push_str("{% endif %}"),
308 "role" => {
309 out.push_str("{% if has_role(auth_user, ");
310 out.push_str(args_inner);
311 out.push_str(") %}");
312 }
313 "endrole" => out.push_str("{% endif %}"),
314
315 "checked" => emit_attr_helper(&mut out, "checked", args_inner),
317 "selected" => emit_attr_helper(&mut out, "selected", args_inner),
318 "disabled" => emit_attr_helper(&mut out, "disabled", args_inner),
319 "readonly" => emit_attr_helper(&mut out, "readonly", args_inner),
320 "required" => emit_attr_helper(&mut out, "required", args_inner),
321
322 "error" => {
324 out.push_str("{% if let Some(message) = errors.get(\"");
326 out.push_str(args_stripped_quotes);
327 out.push_str("\").and_then(|v| v.first()) %}");
328 }
329 "enderror" => out.push_str("{% endif %}"),
330 "old" => {
331 let (field, default) = parse_old_args(args_inner);
333 out.push_str("{{ ::forge::escape::html(&old_input.get(\"");
334 out.push_str(&field);
335 out.push_str("\").cloned().unwrap_or_else(|| ");
336 out.push_str(&default);
337 out.push_str(".to_string())) }}");
338 }
339
340 "class" => {
342 out.push_str("class=\"{{ ::forge::escape::html(&::forge::class_list(&[");
344 out.push_str(args_inner);
345 out.push_str("])) }}\"");
346 }
347 "style" => {
348 out.push_str("style=\"{{ ::forge::escape::html(&::forge::style_list(&[");
349 out.push_str(args_inner);
350 out.push_str("])) }}\"");
351 }
352
353 "dump" | "dd" => {
355 out.push_str("<pre>{{ format!(\"{:#?}\", ");
356 out.push_str(args_inner);
357 out.push_str(")|escape }}</pre>");
358 }
359 "json" => {
360 out.push_str("{{ ::serde_json::to_string(&");
362 out.push_str(args_inner);
363 out.push_str(").unwrap_or_default()|safe }}");
364 }
365
366 "verbatim" | "endverbatim" => {}
368
369 "csrf" => {
371 out.push_str(
372 "<input type=\"hidden\" name=\"_token\" value=\"{{ csrf_token }}\">",
373 );
374 }
375 "method" => {
376 out.push_str("<input type=\"hidden\" name=\"_method\" value=\"");
377 out.push_str(args_stripped_quotes);
378 out.push_str("\">");
379 }
380
381 "lang" | "trans" => {
383 out.push_str("{{ ::forge::escape::html(&::forge::lang(");
384 out.push_str(args_inner);
385 out.push_str(")) }}");
386 }
387 "choice" => {
388 out.push_str("{{ ::forge::escape::html(&::forge::lang_choice(");
389 out.push_str(args_inner);
390 out.push_str(")) }}");
391 }
392
393 "props" => {
395 out.push_str("{# props: ");
398 out.push_str(args_inner);
399 out.push_str(" #}");
400 }
401
402 "spark" => match target {
405 LowerTarget::Askama => {
406 let (name, props_expr) = parse_spark_args(args_inner);
407 out.push_str("{{ ::spark::render::render_mount(\"");
408 out.push_str(&name.replace('"', "\\\""));
409 out.push_str("\", &::spark::serde_json::json!(");
410 if props_expr.is_empty() {
411 out.push_str("null");
412 } else {
413 out.push_str(&props_expr);
414 }
415 out.push_str(")).unwrap_or_default()|safe }}");
416 }
417 LowerTarget::MiniJinja => {
418 let (name, props_expr) = parse_spark_args(args_inner);
423 out.push_str("{{ spark_mount(\"");
424 out.push_str(&name.replace('"', "\\\""));
425 if props_expr.is_empty() {
426 out.push_str("\")|safe }}");
427 } else {
428 out.push_str("\", ");
429 out.push_str("e_dict_keys(&props_expr));
430 out.push_str(")|safe }}");
431 }
432 }
433 },
434
435 "sparkScripts" | "sparkscripts" => match target {
437 LowerTarget::Askama => {
438 out.push_str("{{ ::spark::render::boot_script()|safe }}");
439 }
440 LowerTarget::MiniJinja => {
441 out.push_str("{{ spark_scripts()|safe }}");
442 }
443 },
444
445 "sparkIsland" | "sparkisland" => {
447 let name = args_stripped_quotes;
448 out.push_str("<div spark:island=\"");
449 out.push_str(name);
450 out.push_str("\">");
451 }
452 "endSparkIsland" | "endsparkisland" => {
453 out.push_str("</div>");
454 }
455
456 _ => {
458 out.push_str("{# unknown directive @");
459 out.push_str(name);
460 if !args_inner.is_empty() {
461 out.push('(');
462 out.push_str(args_inner);
463 out.push(')');
464 }
465 out.push_str(" #}");
466 }
467 }
468 }
469
470 Token::ComponentOpen {
471 name,
472 attrs,
473 self_closing,
474 } => {
475 let component_macro = component_macro_name(name);
477 out.push_str("{% call ");
478 out.push_str(&component_macro);
479 out.push('(');
480 let mut first = true;
481 for (k, v) in attrs {
482 if !first {
483 out.push_str(", ");
484 }
485 first = false;
486 out.push_str(k);
487 out.push_str("=\"");
488 out.push_str(v);
489 out.push('"');
490 }
491 out.push_str(") %}");
492 if *self_closing {
493 out.push_str("{% endcall %}");
494 }
495 }
496
497 Token::ComponentClose { .. } => {
498 out.push_str("{% endcall %}");
499 }
500 }
501 }
502
503 out
504}
505
506fn emit_if(out: &mut String, expr: &str) {
509 out.push_str("{% if ");
510 out.push_str(expr);
511 out.push_str(" %}");
512}
513
514fn emit_elif(out: &mut String, expr: &str) {
515 out.push_str("{% elif ");
516 out.push_str(expr);
517 out.push_str(" %}");
518}
519
520fn emit_attr_helper(out: &mut String, attr: &str, condition: &str) {
521 out.push_str("{% if (");
522 out.push_str(condition);
523 out.push_str(") %}");
524 out.push_str(attr);
525 out.push_str("{% endif %}");
526}
527
528fn parse_inline_section(args: &str) -> Option<(String, String)> {
529 let args = args.trim();
531 if let Some(comma) = find_top_level_comma(args) {
532 let name = args[..comma]
533 .trim()
534 .trim_matches(|c| c == '"' || c == '\'')
535 .to_string();
536 let value = args[comma + 1..].trim().to_string();
537 if !name.is_empty() {
538 return Some((name, value));
539 }
540 }
541 None
542}
543
544fn parse_yield_args(args: &str) -> (String, String) {
545 if let Some(comma) = find_top_level_comma(args) {
546 let n = args[..comma]
547 .trim()
548 .trim_matches(|c| c == '"' || c == '\'')
549 .to_string();
550 let d_raw = args[comma + 1..].trim();
551 let d = if d_raw.starts_with('"') || d_raw.starts_with('\'') {
552 d_raw.trim_matches(|c| c == '"' || c == '\'').to_string()
554 } else {
555 format!("{{{{ {d_raw} }}}}")
557 };
558 return (n, d);
559 }
560 (
561 args.trim()
562 .trim_matches(|c| c == '"' || c == '\'')
563 .to_string(),
564 String::new(),
565 )
566}
567
568fn parse_old_args(args: &str) -> (String, String) {
569 if let Some(comma) = find_top_level_comma(args) {
570 let f = args[..comma]
571 .trim()
572 .trim_matches(|c| c == '"' || c == '\'')
573 .to_string();
574 let d = args[comma + 1..].trim().to_string();
575 return (f, d);
576 }
577 (
578 args.trim()
579 .trim_matches(|c| c == '"' || c == '\'')
580 .to_string(),
581 "\"\"".to_string(),
582 )
583}
584
585fn quote_dict_keys(input: &str) -> String {
592 let bytes = input.as_bytes();
593 let mut out = String::with_capacity(input.len() + 16);
594 let mut i = 0;
595 let mut in_str: Option<u8> = None;
596
597 while i < bytes.len() {
598 let b = bytes[i];
599
600 if let Some(q) = in_str {
602 out.push(b as char);
603 if b == q && bytes.get(i.saturating_sub(1)) != Some(&b'\\') {
604 in_str = None;
605 }
606 i += 1;
607 continue;
608 }
609 if b == b'"' || b == b'\'' {
610 in_str = Some(b);
611 out.push(b as char);
612 i += 1;
613 continue;
614 }
615
616 if b == b'{' || b == b',' {
618 out.push(b as char);
619 i += 1;
620 while i < bytes.len() && (bytes[i] as char).is_whitespace() {
622 out.push(bytes[i] as char);
623 i += 1;
624 }
625 if i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
627 let start = i;
628 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
629 i += 1;
630 }
631 let ident = &input[start..i];
632 let mut j = i;
634 while j < bytes.len() && (bytes[j] as char).is_whitespace() {
635 j += 1;
636 }
637 if j < bytes.len() && bytes[j] == b':' {
638 out.push('"');
639 out.push_str(ident);
640 out.push('"');
641 out.push_str(&input[i..j]);
645 out.push(':');
646 i = j + 1;
647 continue;
648 } else {
649 out.push_str(ident);
651 continue;
652 }
653 }
654 continue;
655 }
656
657 out.push(b as char);
658 i += 1;
659 }
660 out
661}
662
663fn parse_spark_args(args: &str) -> (String, String) {
670 let args = args.trim();
671 if args.is_empty() {
672 return (String::new(), String::new());
673 }
674 if let Some(comma) = find_top_level_comma(args) {
675 let name = args[..comma]
676 .trim()
677 .trim_matches(|c| c == '"' || c == '\'')
678 .to_string();
679 let props = args[comma + 1..].trim().to_string();
680 return (name, props);
681 }
682 (
683 args.trim_matches(|c: char| c == '"' || c == '\'')
684 .to_string(),
685 String::new(),
686 )
687}
688
689fn find_top_level_comma(s: &str) -> Option<usize> {
690 let mut depth_paren = 0;
691 let mut depth_bracket = 0;
692 let mut in_string = None::<char>;
693 for (i, ch) in s.char_indices() {
694 if let Some(q) = in_string {
695 if ch == q {
696 in_string = None;
697 }
698 continue;
699 }
700 match ch {
701 '"' | '\'' => in_string = Some(ch),
702 '(' => depth_paren += 1,
703 ')' => depth_paren -= 1,
704 '[' => depth_bracket += 1,
705 ']' => depth_bracket -= 1,
706 ',' if depth_paren == 0 && depth_bracket == 0 => return Some(i),
707 _ => {}
708 }
709 }
710 None
711}
712
713fn path_for(spec: &str) -> String {
714 let s = spec.replace('.', "/");
715 if s.ends_with(".html") {
716 s
717 } else {
718 format!("{s}.html")
719 }
720}
721
722fn component_macro_name(name: &str) -> String {
723 name.replace('-', "_")
724}
725
726fn split_foreach(args: &str) -> (String, String) {
727 let args = args.trim();
728 if let Some((lhs, rhs)) = args.split_once(" as ") {
729 let lhs = lhs.trim().trim_start_matches('$').to_string();
730 let rhs = rhs.trim().trim_start_matches('$').to_string();
731 if let Some((_k, v)) = rhs.split_once("=>") {
732 return (lhs, v.trim().trim_start_matches('$').to_string());
733 }
734 (lhs, rhs)
735 } else {
736 (args.to_string(), "item".to_string())
737 }
738}