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(); // names of currently-open @push blocks
8
9    for tok in tokens {
10        match tok {
11            Token::Text(s) => out.push_str(s),
12
13            Token::EscapedExpr(expr) => {
14                out.push_str("{{ ");
15                out.push_str(expr);
16                out.push_str(" }}");
17            }
18
19            Token::RawExpr(expr) => {
20                out.push_str("{{ ");
21                out.push_str(expr);
22                out.push_str("|safe }}");
23            }
24
25            Token::Directive { name, args } => {
26                let args_inner = args.as_deref().unwrap_or("").trim();
27                let args_stripped_quotes =
28                    args_inner.trim_matches(|c| c == '"' || c == '\'');
29
30                match name.as_str() {
31                    // Control flow
32                    "if" => {
33                        out.push_str("{% if ");
34                        out.push_str(args_inner);
35                        out.push_str(" %}");
36                    }
37                    "elseif" => {
38                        out.push_str("{% elif ");
39                        out.push_str(args_inner);
40                        out.push_str(" %}");
41                    }
42                    "else" => out.push_str("{% else %}"),
43                    "endif" => out.push_str("{% endif %}"),
44
45                    "unless" => {
46                        out.push_str("{% if !(");
47                        out.push_str(args_inner);
48                        out.push_str(") %}");
49                    }
50                    "endunless" => out.push_str("{% endif %}"),
51
52                    "foreach" => {
53                        // @foreach($items as $item)  →  {% for item in items %}
54                        let (lhs, rhs) = split_foreach(args_inner);
55                        out.push_str("{% for ");
56                        out.push_str(&rhs);
57                        out.push_str(" in ");
58                        out.push_str(&lhs);
59                        out.push_str(" %}");
60                    }
61                    "endforeach" => out.push_str("{% endfor %}"),
62
63                    "for" => {
64                        out.push_str("{% for ");
65                        out.push_str(args_inner);
66                        out.push_str(" %}");
67                    }
68                    "endfor" => out.push_str("{% endfor %}"),
69
70                    "while" => {
71                        // Askama doesn't have @while; lower to a comment so build doesn't crash.
72                        out.push_str("{# @while not supported, use @foreach: ");
73                        out.push_str(args_inner);
74                        out.push_str(" #}");
75                    }
76                    "endwhile" => {}
77
78                    // Layout
79                    "extends" => {
80                        out.push_str("{% extends \"");
81                        out.push_str(&path_for(args_stripped_quotes));
82                        out.push_str("\" %}");
83                    }
84                    "section" => {
85                        out.push_str("{% block ");
86                        out.push_str(args_stripped_quotes);
87                        out.push_str(" %}");
88                    }
89                    "endsection" => out.push_str("{% endblock %}"),
90                    "yield" => {
91                        out.push_str("{% block ");
92                        out.push_str(args_stripped_quotes);
93                        out.push_str(" %}{% endblock %}");
94                    }
95                    "parent" => out.push_str("{{ super() }}"),
96                    "include" => {
97                        out.push_str("{% include \"");
98                        out.push_str(&path_for(args_stripped_quotes));
99                        out.push_str("\" %}");
100                    }
101                    "includeIf" | "includeif" => {
102                        // simplistic: just include
103                        out.push_str("{% include \"");
104                        out.push_str(&path_for(args_stripped_quotes));
105                        out.push_str("\" %}");
106                    }
107
108                    // Stacks
109                    "stack" => {
110                        // Emit placeholder. forge::stack::postprocess fills it.
111                        out.push_str("<!--FORGE-STACK:");
112                        out.push_str(args_stripped_quotes);
113                        out.push_str("-->");
114                    }
115                    "push" => {
116                        stack_pushes.push(args_stripped_quotes.to_string());
117                        // Capture into a sub-block whose rendered content is sent to stack buffer.
118                        // We do this by wrapping the content in a fake block and using a marker
119                        // pre/post that the postprocess pass extracts.
120                        out.push_str("<!--FORGE-PUSH-START:");
121                        out.push_str(args_stripped_quotes);
122                        out.push_str("-->");
123                    }
124                    "endpush" => {
125                        let name = stack_pushes.pop().unwrap_or_default();
126                        out.push_str("<!--FORGE-PUSH-END:");
127                        out.push_str(&name);
128                        out.push_str("-->");
129                    }
130                    "prepend" => {
131                        stack_pushes.push(args_stripped_quotes.to_string());
132                        out.push_str("<!--FORGE-PREPEND-START:");
133                        out.push_str(args_stripped_quotes);
134                        out.push_str("-->");
135                    }
136                    "endprepend" => {
137                        let name = stack_pushes.pop().unwrap_or_default();
138                        out.push_str("<!--FORGE-PREPEND-END:");
139                        out.push_str(&name);
140                        out.push_str("-->");
141                    }
142
143                    // Vite
144                    "vite" => {
145                        // @vite(['resources/css/app.css', 'resources/js/app.js']) → call helper
146                        out.push_str("{{ ::forge::vite::render(&[");
147                        out.push_str(args_inner);
148                        out.push_str("])|safe }}");
149                    }
150
151                    // Auth / Can — sugar over @if
152                    "auth" => {
153                        out.push_str("{% if auth_user.is_some() %}");
154                    }
155                    "endauth" => out.push_str("{% endif %}"),
156                    "guest" => out.push_str("{% if auth_user.is_none() %}"),
157                    "endguest" => out.push_str("{% endif %}"),
158                    "can" => {
159                        out.push_str("{% if can(");
160                        out.push_str(args_inner);
161                        out.push_str(") %}");
162                    }
163                    "endcan" => out.push_str("{% endif %}"),
164
165                    // Verbatim is a no-op in Askama (no PHP-like raw mode required)
166                    "verbatim" | "endverbatim" => {}
167
168                    // CSRF token helper
169                    "csrf" => {
170                        out.push_str(
171                            "<input type=\"hidden\" name=\"_token\" value=\"{{ csrf_token }}\">",
172                        );
173                    }
174
175                    // Method spoofing for forms
176                    "method" => {
177                        out.push_str("<input type=\"hidden\" name=\"_method\" value=\"");
178                        out.push_str(args_stripped_quotes);
179                        out.push_str("\">");
180                    }
181
182                    // Unknown directive — emit a passthrough comment so build doesn't crash
183                    _ => {
184                        out.push_str("{# unknown directive @");
185                        out.push_str(name);
186                        if !args_inner.is_empty() {
187                            out.push('(');
188                            out.push_str(args_inner);
189                            out.push(')');
190                        }
191                        out.push_str(" #}");
192                    }
193                }
194            }
195
196            Token::ComponentOpen {
197                name,
198                attrs,
199                self_closing,
200            } => {
201                // Lower <x-alert type="error"> → {% call alert(type="error") %}
202                let component_macro = component_macro_name(name);
203                out.push_str("{% call ");
204                out.push_str(&component_macro);
205                out.push('(');
206                let mut first = true;
207                for (k, v) in attrs {
208                    if !first {
209                        out.push_str(", ");
210                    }
211                    first = false;
212                    out.push_str(k);
213                    out.push_str("=\"");
214                    out.push_str(v);
215                    out.push('"');
216                }
217                out.push_str(") %}");
218                if *self_closing {
219                    out.push_str("{% endcall %}");
220                }
221            }
222
223            Token::ComponentClose { .. } => {
224                out.push_str("{% endcall %}");
225            }
226        }
227    }
228
229    out
230}
231
232fn path_for(spec: &str) -> String {
233    // `layouts.app` → `layouts/app.html`
234    let s = spec.replace('.', "/");
235    if s.ends_with(".html") {
236        s
237    } else {
238        format!("{s}.html")
239    }
240}
241
242fn component_macro_name(name: &str) -> String {
243    name.replace('-', "_")
244}
245
246fn split_foreach(args: &str) -> (String, String) {
247    // Forms:
248    // "$items as $item" → ("items", "item")
249    // "items as item" → ("items", "item")
250    // "$users as $key => $user" → currently unsupported; collapse to items/users
251    let args = args.trim();
252    if let Some((lhs, rhs)) = args.split_once(" as ") {
253        let lhs = lhs.trim().trim_start_matches('$').to_string();
254        let rhs = rhs.trim().trim_start_matches('$').to_string();
255        if let Some((_k, v)) = rhs.split_once("=>") {
256            return (lhs, v.trim().trim_start_matches('$').to_string());
257        }
258        (lhs, rhs)
259    } else {
260        (args.to_string(), "item".to_string())
261    }
262}