Skip to main content

forge_codegen/
lower.rs

1//! Lower a Forge token stream into Askama template syntax.
2
3use crate::parser::Token;
4
5pub fn lower(tokens: &[Token]) -> String {
6    let mut out = String::new();
7    let mut stack_pushes: Vec<String> = Vec::new();
8    let mut forelse_stack: Vec<String> = Vec::new(); // collection identifier per @forelse
9    let mut switch_started = false; // whether we've emitted the first {% if %} for a @switch
10
11    for tok in tokens {
12        match tok {
13            Token::Text(s) => out.push_str(s),
14
15            Token::EscapedExpr(expr) => {
16                out.push_str("{{ ");
17                out.push_str(expr);
18                out.push_str(" }}");
19            }
20
21            Token::RawExpr(expr) => {
22                out.push_str("{{ ");
23                out.push_str(expr);
24                out.push_str("|safe }}");
25            }
26
27            Token::Directive { name, args } => {
28                let args_inner = args.as_deref().unwrap_or("").trim();
29                let args_stripped_quotes =
30                    args_inner.trim_matches(|c| c == '"' || c == '\'');
31
32                match name.as_str() {
33                    // ─── Control flow ──────────────────────────────────────
34                    "if" => emit_if(&mut out, args_inner),
35                    "elseif" => emit_elif(&mut out, args_inner),
36                    "else" => out.push_str("{% else %}"),
37                    "endif" => out.push_str("{% endif %}"),
38
39                    "unless" => {
40                        out.push_str("{% if !(");
41                        out.push_str(args_inner);
42                        out.push_str(") %}");
43                    }
44                    "endunless" => out.push_str("{% endif %}"),
45
46                    "isset" => {
47                        // Askama doesn't have isset; treat Option types via .is_some()
48                        out.push_str("{% if (");
49                        out.push_str(args_inner);
50                        out.push_str(").is_some() %}");
51                    }
52                    "endisset" => out.push_str("{% endif %}"),
53
54                    "empty" if !forelse_stack.is_empty() => {
55                        // Inside @forelse: end the for and emit else branch.
56                        out.push_str("{% endfor %}");
57                        let _ = forelse_stack.pop();
58                        out.push_str("{% if true %}");
59                        // Track the @empty branch as a pseudo-if so @endforelse closes it.
60                        forelse_stack.push("__empty_branch__".to_string());
61                    }
62                    "empty" => {
63                        // Standalone @empty: shorthand for @if(value.is_empty())
64                        out.push_str("{% if (");
65                        out.push_str(args_inner);
66                        out.push_str(").is_empty() %}");
67                    }
68                    "endempty" => out.push_str("{% endif %}"),
69
70                    // ─── Loops ─────────────────────────────────────────────
71                    "foreach" => {
72                        let (lhs, rhs) = split_foreach(args_inner);
73                        out.push_str("{% for ");
74                        out.push_str(&rhs);
75                        out.push_str(" in ");
76                        out.push_str(&lhs);
77                        out.push_str(" %}");
78                    }
79                    "endforeach" => out.push_str("{% endfor %}"),
80
81                    "forelse" => {
82                        let (lhs, rhs) = split_foreach(args_inner);
83                        forelse_stack.push(lhs.clone());
84                        out.push_str("{% for ");
85                        out.push_str(&rhs);
86                        out.push_str(" in ");
87                        out.push_str(&lhs);
88                        out.push_str(" %}");
89                    }
90                    "endforelse" => {
91                        // Close whichever branch we're in.
92                        if let Some(top) = forelse_stack.pop() {
93                            if top == "__empty_branch__" {
94                                out.push_str("{% endif %}");
95                            } else {
96                                out.push_str("{% endfor %}");
97                            }
98                        }
99                    }
100
101                    "for" => {
102                        out.push_str("{% for ");
103                        out.push_str(args_inner);
104                        out.push_str(" %}");
105                    }
106                    "endfor" => out.push_str("{% endfor %}"),
107
108                    "continue" => {
109                        if args_inner.is_empty() {
110                            out.push_str("{% continue %}");
111                        } else {
112                            out.push_str("{% if ");
113                            out.push_str(args_inner);
114                            out.push_str(" %}{% continue %}{% endif %}");
115                        }
116                    }
117                    "break" => {
118                        if args_inner.is_empty() {
119                            out.push_str("{% break %}");
120                        } else {
121                            out.push_str("{% if ");
122                            out.push_str(args_inner);
123                            out.push_str(" %}{% break %}{% endif %}");
124                        }
125                    }
126
127                    "while" => {
128                        out.push_str("{# @while not supported, use @foreach: ");
129                        out.push_str(args_inner);
130                        out.push_str(" #}");
131                    }
132                    "endwhile" => {}
133
134                    // ─── Switch ────────────────────────────────────────────
135                    "switch" => {
136                        out.push_str("{# @switch(");
137                        out.push_str(args_inner);
138                        out.push_str(") #}");
139                        switch_started = false;
140                        // Save the switch expression for case matching.
141                        // We rely on the user repeating it in @case for clarity.
142                        out.push_str("<!--SWITCH:");
143                        out.push_str(args_inner);
144                        out.push_str("-->");
145                    }
146                    "case" => {
147                        // Translate @case(value) to either {% if switch_expr == value %} or {% elif ... %}
148                        // We don't track the switch expr here; use a sentinel `__switch__` that downstream
149                        // tooling can patch. For practical use, prefer @if/@elseif explicitly.
150                        if !switch_started {
151                            out.push_str("{% if __switch__ == ");
152                            switch_started = true;
153                        } else {
154                            out.push_str("{% elif __switch__ == ");
155                        }
156                        out.push_str(args_inner);
157                        out.push_str(" %}");
158                    }
159                    "default" => {
160                        if switch_started {
161                            out.push_str("{% else %}");
162                        }
163                    }
164                    "endswitch" => {
165                        if switch_started {
166                            out.push_str("{% endif %}");
167                            switch_started = false;
168                        }
169                    }
170
171                    // ─── Layout / inheritance ──────────────────────────────
172                    "extends" => {
173                        out.push_str("{% extends \"");
174                        out.push_str(&path_for(args_stripped_quotes));
175                        out.push_str("\" %}");
176                    }
177                    "section" => {
178                        // Support both block form (@section/@endsection) and inline (@section('title', 'My title'))
179                        if let Some((name, value)) = parse_inline_section(args_inner) {
180                            out.push_str("{% block ");
181                            out.push_str(&name);
182                            out.push_str(" %}");
183                            out.push_str(&value);
184                            out.push_str("{% endblock %}");
185                        } else {
186                            out.push_str("{% block ");
187                            out.push_str(args_stripped_quotes);
188                            out.push_str(" %}");
189                        }
190                    }
191                    "endsection" | "show" | "stop" | "overwrite" => out.push_str("{% endblock %}"),
192                    "yield" => {
193                        // @yield('name') or @yield('name', 'default') — Askama doesn't have a one-liner
194                        // default; use {% block %}default{% endblock %} so subtemplates can override.
195                        let (n, d) = parse_yield_args(args_inner);
196                        out.push_str("{% block ");
197                        out.push_str(&n);
198                        out.push_str(" %}");
199                        out.push_str(&d);
200                        out.push_str("{% endblock %}");
201                    }
202                    "parent" => out.push_str("{{ super() }}"),
203                    "include" => {
204                        out.push_str("{% include \"");
205                        out.push_str(&path_for(args_stripped_quotes));
206                        out.push_str("\" %}");
207                    }
208                    "includeIf" | "includeif" | "includeWhen" | "includewhen" => {
209                        out.push_str("{% include \"");
210                        out.push_str(&path_for(args_stripped_quotes));
211                        out.push_str("\" %}");
212                    }
213                    "hasSection" | "hassection" => {
214                        out.push_str("{% if true %}{# @hasSection placeholder for '");
215                        out.push_str(args_stripped_quotes);
216                        out.push_str("' #}");
217                    }
218                    "endhasSection" | "endhassection" | "sectionMissing" | "sectionmissing" => {
219                        out.push_str("{% endif %}");
220                    }
221
222                    // ─── Stacks ────────────────────────────────────────────
223                    "stack" => {
224                        out.push_str("<!--FORGE-STACK:");
225                        out.push_str(args_stripped_quotes);
226                        out.push_str("-->");
227                    }
228                    "push" => {
229                        stack_pushes.push(args_stripped_quotes.to_string());
230                        out.push_str("<!--FORGE-PUSH-START:");
231                        out.push_str(args_stripped_quotes);
232                        out.push_str("-->");
233                    }
234                    "endpush" => {
235                        let name = stack_pushes.pop().unwrap_or_default();
236                        out.push_str("<!--FORGE-PUSH-END:");
237                        out.push_str(&name);
238                        out.push_str("-->");
239                    }
240                    "pushOnce" | "pushonce" => {
241                        stack_pushes.push(args_stripped_quotes.to_string());
242                        out.push_str("<!--FORGE-PUSHONCE-START:");
243                        out.push_str(args_stripped_quotes);
244                        out.push_str("-->");
245                    }
246                    "endPushOnce" | "endpushonce" => {
247                        let name = stack_pushes.pop().unwrap_or_default();
248                        out.push_str("<!--FORGE-PUSHONCE-END:");
249                        out.push_str(&name);
250                        out.push_str("-->");
251                    }
252                    "prepend" => {
253                        stack_pushes.push(args_stripped_quotes.to_string());
254                        out.push_str("<!--FORGE-PREPEND-START:");
255                        out.push_str(args_stripped_quotes);
256                        out.push_str("-->");
257                    }
258                    "endprepend" => {
259                        let name = stack_pushes.pop().unwrap_or_default();
260                        out.push_str("<!--FORGE-PREPEND-END:");
261                        out.push_str(&name);
262                        out.push_str("-->");
263                    }
264                    "once" => out.push_str("<!--FORGE-ONCE-START-->"),
265                    "endonce" => out.push_str("<!--FORGE-ONCE-END-->"),
266
267                    // ─── Assets ────────────────────────────────────────────
268                    "vite" => {
269                        out.push_str("{{ ::forge::vite::render(&[");
270                        out.push_str(args_inner);
271                        out.push_str("])|safe }}");
272                    }
273
274                    // ─── Auth / authorization ──────────────────────────────
275                    "auth" => out.push_str("{% if auth_user.is_some() %}"),
276                    "endauth" => out.push_str("{% endif %}"),
277                    "guest" => out.push_str("{% if auth_user.is_none() %}"),
278                    "endguest" => out.push_str("{% endif %}"),
279                    "can" => {
280                        out.push_str("{% if can(");
281                        out.push_str(args_inner);
282                        out.push_str(") %}");
283                    }
284                    "endcan" => out.push_str("{% endif %}"),
285                    "cannot" => {
286                        out.push_str("{% if !can(");
287                        out.push_str(args_inner);
288                        out.push_str(") %}");
289                    }
290                    "endcannot" => out.push_str("{% endif %}"),
291                    "role" => {
292                        out.push_str("{% if has_role(auth_user, ");
293                        out.push_str(args_inner);
294                        out.push_str(") %}");
295                    }
296                    "endrole" => out.push_str("{% endif %}"),
297
298                    // ─── Form attribute helpers ────────────────────────────
299                    "checked" => emit_attr_helper(&mut out, "checked", args_inner),
300                    "selected" => emit_attr_helper(&mut out, "selected", args_inner),
301                    "disabled" => emit_attr_helper(&mut out, "disabled", args_inner),
302                    "readonly" => emit_attr_helper(&mut out, "readonly", args_inner),
303                    "required" => emit_attr_helper(&mut out, "required", args_inner),
304
305                    // ─── Validation feedback ───────────────────────────────
306                    "error" => {
307                        // @error('field') ... @enderror — exposes `message` inside the block.
308                        out.push_str("{% if let Some(message) = errors.get(\"");
309                        out.push_str(args_stripped_quotes);
310                        out.push_str("\").and_then(|v| v.first()) %}");
311                    }
312                    "enderror" => out.push_str("{% endif %}"),
313                    "old" => {
314                        // @old('field', 'default') — renders the old input value via runtime helper.
315                        let (field, default) = parse_old_args(args_inner);
316                        out.push_str("{{ ::forge::escape::html(&old_input.get(\"");
317                        out.push_str(&field);
318                        out.push_str("\").cloned().unwrap_or_else(|| ");
319                        out.push_str(&default);
320                        out.push_str(".to_string())) }}");
321                    }
322
323                    // ─── Conditional class & style ─────────────────────────
324                    "class" => {
325                        // @class([("active", is_active), ("muted", !is_active)])
326                        out.push_str("class=\"{{ ::forge::escape::html(&::forge::class_list(&[");
327                        out.push_str(args_inner);
328                        out.push_str("])) }}\"");
329                    }
330                    "style" => {
331                        out.push_str("style=\"{{ ::forge::escape::html(&::forge::style_list(&[");
332                        out.push_str(args_inner);
333                        out.push_str("])) }}\"");
334                    }
335
336                    // ─── Debug helpers ─────────────────────────────────────
337                    "dump" | "dd" => {
338                        out.push_str("<pre>{{ format!(\"{:#?}\", ");
339                        out.push_str(args_inner);
340                        out.push_str(")|escape }}</pre>");
341                    }
342                    "json" => {
343                        // @json($var) — emit as JSON inside <script> safely.
344                        out.push_str("{{ ::serde_json::to_string(&");
345                        out.push_str(args_inner);
346                        out.push_str(").unwrap_or_default()|safe }}");
347                    }
348
349                    // ─── Verbatim ──────────────────────────────────────────
350                    "verbatim" | "endverbatim" => {}
351
352                    // ─── CSRF / method spoofing ────────────────────────────
353                    "csrf" => {
354                        out.push_str(
355                            "<input type=\"hidden\" name=\"_token\" value=\"{{ csrf_token }}\">",
356                        );
357                    }
358                    "method" => {
359                        out.push_str("<input type=\"hidden\" name=\"_method\" value=\"");
360                        out.push_str(args_stripped_quotes);
361                        out.push_str("\">");
362                    }
363
364                    // ─── i18n placeholders (real impl in v0.2) ─────────────
365                    "lang" | "trans" => {
366                        out.push_str("{{ ::forge::escape::html(&::forge::lang(");
367                        out.push_str(args_inner);
368                        out.push_str(")) }}");
369                    }
370                    "choice" => {
371                        out.push_str("{{ ::forge::escape::html(&::forge::lang_choice(");
372                        out.push_str(args_inner);
373                        out.push_str(")) }}");
374                    }
375
376                    // ─── Component prop declaration ────────────────────────
377                    "props" => {
378                        // @props([...]) — for now, emitted as a comment marker;
379                        // real prop-default handling is done at lowering of component templates.
380                        out.push_str("{# props: ");
381                        out.push_str(args_inner);
382                        out.push_str(" #}");
383                    }
384
385                    // ─── Fallback ──────────────────────────────────────────
386                    _ => {
387                        out.push_str("{# unknown directive @");
388                        out.push_str(name);
389                        if !args_inner.is_empty() {
390                            out.push('(');
391                            out.push_str(args_inner);
392                            out.push(')');
393                        }
394                        out.push_str(" #}");
395                    }
396                }
397            }
398
399            Token::ComponentOpen {
400                name,
401                attrs,
402                self_closing,
403            } => {
404                // Lower <x-alert type="error"> → {% call alert(type="error") %}
405                let component_macro = component_macro_name(name);
406                out.push_str("{% call ");
407                out.push_str(&component_macro);
408                out.push('(');
409                let mut first = true;
410                for (k, v) in attrs {
411                    if !first {
412                        out.push_str(", ");
413                    }
414                    first = false;
415                    out.push_str(k);
416                    out.push_str("=\"");
417                    out.push_str(v);
418                    out.push('"');
419                }
420                out.push_str(") %}");
421                if *self_closing {
422                    out.push_str("{% endcall %}");
423                }
424            }
425
426            Token::ComponentClose { .. } => {
427                out.push_str("{% endcall %}");
428            }
429        }
430    }
431
432    out
433}
434
435// ─── helpers ────────────────────────────────────────────────────────────────
436
437fn emit_if(out: &mut String, expr: &str) {
438    out.push_str("{% if ");
439    out.push_str(expr);
440    out.push_str(" %}");
441}
442
443fn emit_elif(out: &mut String, expr: &str) {
444    out.push_str("{% elif ");
445    out.push_str(expr);
446    out.push_str(" %}");
447}
448
449fn emit_attr_helper(out: &mut String, attr: &str, condition: &str) {
450    out.push_str("{% if (");
451    out.push_str(condition);
452    out.push_str(") %}");
453    out.push_str(attr);
454    out.push_str("{% endif %}");
455}
456
457fn parse_inline_section(args: &str) -> Option<(String, String)> {
458    // @section('title', 'My title') → ("title", "My title")
459    let args = args.trim();
460    if let Some(comma) = find_top_level_comma(args) {
461        let name = args[..comma]
462            .trim()
463            .trim_matches(|c| c == '"' || c == '\'')
464            .to_string();
465        let value = args[comma + 1..].trim().to_string();
466        if !name.is_empty() {
467            return Some((name, value));
468        }
469    }
470    None
471}
472
473fn parse_yield_args(args: &str) -> (String, String) {
474    if let Some(comma) = find_top_level_comma(args) {
475        let n = args[..comma]
476            .trim()
477            .trim_matches(|c| c == '"' || c == '\'')
478            .to_string();
479        let d_raw = args[comma + 1..].trim();
480        let d = if d_raw.starts_with('"') || d_raw.starts_with('\'') {
481            // String literal default — emit as is.
482            d_raw.trim_matches(|c| c == '"' || c == '\'').to_string()
483        } else {
484            // Expression default — wrap in {{ }}.
485            format!("{{{{ {d_raw} }}}}")
486        };
487        return (n, d);
488    }
489    (
490        args.trim().trim_matches(|c| c == '"' || c == '\'').to_string(),
491        String::new(),
492    )
493}
494
495fn parse_old_args(args: &str) -> (String, String) {
496    if let Some(comma) = find_top_level_comma(args) {
497        let f = args[..comma]
498            .trim()
499            .trim_matches(|c| c == '"' || c == '\'')
500            .to_string();
501        let d = args[comma + 1..].trim().to_string();
502        return (f, d);
503    }
504    (
505        args.trim().trim_matches(|c| c == '"' || c == '\'').to_string(),
506        "\"\"".to_string(),
507    )
508}
509
510fn find_top_level_comma(s: &str) -> Option<usize> {
511    let mut depth_paren = 0;
512    let mut depth_bracket = 0;
513    let mut in_string = None::<char>;
514    for (i, ch) in s.char_indices() {
515        if let Some(q) = in_string {
516            if ch == q {
517                in_string = None;
518            }
519            continue;
520        }
521        match ch {
522            '"' | '\'' => in_string = Some(ch),
523            '(' => depth_paren += 1,
524            ')' => depth_paren -= 1,
525            '[' => depth_bracket += 1,
526            ']' => depth_bracket -= 1,
527            ',' if depth_paren == 0 && depth_bracket == 0 => return Some(i),
528            _ => {}
529        }
530    }
531    None
532}
533
534fn path_for(spec: &str) -> String {
535    let s = spec.replace('.', "/");
536    if s.ends_with(".html") {
537        s
538    } else {
539        format!("{s}.html")
540    }
541}
542
543fn component_macro_name(name: &str) -> String {
544    name.replace('-', "_")
545}
546
547fn split_foreach(args: &str) -> (String, String) {
548    let args = args.trim();
549    if let Some((lhs, rhs)) = args.split_once(" as ") {
550        let lhs = lhs.trim().trim_start_matches('$').to_string();
551        let rhs = rhs.trim().trim_start_matches('$').to_string();
552        if let Some((_k, v)) = rhs.split_once("=>") {
553            return (lhs, v.trim().trim_start_matches('$').to_string());
554        }
555        (lhs, rhs)
556    } else {
557        (args.to_string(), "item".to_string())
558    }
559}