1use crate::source::syntax_error;
8use rustlavel_core::Result;
9
10const WITH_ARGUMENTS: [&str; 9] =
12 ["if", "elseif", "foreach", "extends", "section", "yield", "include", "lang", "route"];
13
14const WITHOUT_ARGUMENTS: [&str; 4] = ["else", "endif", "endforeach", "endsection"];
16
17const STRUCTURAL: [&str; 9] = [
23 "if",
24 "elseif",
25 "else",
26 "endif",
27 "foreach",
28 "endforeach",
29 "section",
30 "endsection",
31 "extends",
32];
33
34#[derive(Debug, Clone, PartialEq)]
36pub enum Token {
37 Text(String),
39 Echo { source: String, offset: usize, escaped: bool },
41 Directive { name: String, arguments: String, arguments_offset: usize, offset: usize },
43}
44
45pub fn tokenize(file: &str, source: &str) -> Result<Vec<Token>> {
47 let mut lexer = Lexer { file, source, pos: 0, text: String::new(), tokens: Vec::new() };
48 lexer.run()?;
49 Ok(lexer.tokens)
50}
51
52struct Lexer<'a> {
53 file: &'a str,
54 source: &'a str,
55 pos: usize,
56 text: String,
58 tokens: Vec<Token>,
59}
60
61impl Lexer<'_> {
62 fn run(&mut self) -> Result<()> {
63 let source = self.source;
65
66 while self.pos < source.len() {
67 let rest = &source[self.pos..];
68
69 match rest.find(['{', '@']) {
72 None => {
73 self.text.push_str(rest);
74 self.pos = source.len();
75 continue;
76 }
77 Some(0) => {}
78 Some(index) => {
79 self.text.push_str(&rest[..index]);
80 self.pos += index;
81 continue;
82 }
83 }
84
85 let rest = &source[self.pos..];
86 if rest.starts_with("{{--") {
87 let end = self.find_close("--}}", self.pos + 4, false, "{{--")?;
88 self.pos = end + 4;
89 } else if rest.starts_with("{!!") {
90 self.echo(3, "!!}", false)?;
91 } else if rest.starts_with("{{") {
92 self.echo(2, "}}", true)?;
93 } else if rest.starts_with("@@") {
94 self.text.push('@');
96 self.pos += 2;
97 } else if rest.starts_with("@{{") {
98 self.text.push_str("{{");
100 self.pos += 3;
101 } else if rest.starts_with('@') && self.directive()? {
102 continue;
103 } else {
104 self.text.push(source.as_bytes()[self.pos] as char);
107 self.pos += 1;
108 }
109 }
110
111 self.flush();
112 Ok(())
113 }
114
115 fn flush(&mut self) {
117 if !self.text.is_empty() {
118 self.tokens.push(Token::Text(std::mem::take(&mut self.text)));
119 }
120 }
121
122 fn echo(&mut self, open: usize, close: &str, escaped: bool) -> Result<()> {
123 let source = self.source;
124 let start = self.pos + open;
125 let end = self.find_close(close, start, true, &source[self.pos..start])?;
126 self.flush();
127 self.tokens.push(Token::Echo {
128 source: source[start..end].to_string(),
129 offset: start,
130 escaped,
131 });
132 self.pos = end + close.len();
133 Ok(())
134 }
135
136 fn directive(&mut self) -> Result<bool> {
141 let source = self.source;
142 let start = self.pos;
143 let bytes = source.as_bytes();
144 let mut end = start + 1;
145 while matches!(bytes.get(end), Some(byte) if byte.is_ascii_alphabetic()) {
146 end += 1;
147 }
148 let name = &source[start + 1..end];
149
150 if name == "php" || name == "endphp" {
154 return Err(syntax_error(
155 self.file,
156 self.source,
157 start,
158 "`@php` is not supported — compute the value in Rust and pass it to the view",
159 ));
160 }
161
162 let takes_arguments = WITH_ARGUMENTS.contains(&name);
163 if !takes_arguments && !WITHOUT_ARGUMENTS.contains(&name) {
164 return Ok(false);
165 }
166
167 let (arguments, arguments_offset, mut after) = if takes_arguments {
168 if bytes.get(end) != Some(&b'(') {
169 return Err(syntax_error(
170 self.file,
171 self.source,
172 end,
173 format!("expected `(` after `@{name}`"),
174 ));
175 }
176 let close = self.matching_paren(end)?;
177 (source[end + 1..close].to_string(), end + 1, close + 1)
178 } else {
179 (String::new(), end, end)
180 };
181
182 if STRUCTURAL.contains(&name)
184 && self.at_line_start(start)
185 && let Some(next_line) = self.line_end(after)
186 {
187 self.text.truncate(self.text.trim_end_matches([' ', '\t']).len());
188 after = next_line;
189 }
190
191 let name = name.to_string();
192 self.flush();
193 self.tokens.push(Token::Directive { name, arguments, arguments_offset, offset: start });
194 self.pos = after;
195 Ok(true)
196 }
197
198 fn find_close(
201 &self,
202 close: &str,
203 from: usize,
204 quoted: bool,
205 opener: &str,
206 ) -> Result<usize> {
207 let bytes = self.source.as_bytes();
208 let mut i = from;
209 while i < bytes.len() {
210 if quoted && matches!(bytes[i], b'"' | b'\'') {
211 i = self.skip_string(i);
212 continue;
213 }
214 if bytes[i..].starts_with(close.as_bytes()) {
215 return Ok(i);
216 }
217 i += 1;
218 }
219 Err(syntax_error(
220 self.file,
221 self.source,
222 from - opener.len(),
223 format!("`{opener}` is never closed — add a matching `{close}`"),
224 ))
225 }
226
227 fn skip_string(&self, start: usize) -> usize {
229 let bytes = self.source.as_bytes();
230 let quote = bytes[start];
231 let mut i = start + 1;
232 while i < bytes.len() {
233 match bytes[i] {
234 b'\\' => i += 2,
235 byte if byte == quote => return i + 1,
236 _ => i += 1,
237 }
238 }
239 i
240 }
241
242 fn matching_paren(&self, open: usize) -> Result<usize> {
244 let bytes = self.source.as_bytes();
245 let mut depth = 0usize;
246 let mut i = open;
247 while i < bytes.len() {
248 match bytes[i] {
249 b'"' | b'\'' => {
250 i = self.skip_string(i);
251 continue;
252 }
253 b'(' => depth += 1,
254 b')' => {
255 depth -= 1;
256 if depth == 0 {
257 return Ok(i);
258 }
259 }
260 _ => {}
261 }
262 i += 1;
263 }
264 Err(syntax_error(self.file, self.source, open, "`(` is never closed"))
265 }
266
267 fn at_line_start(&self, offset: usize) -> bool {
269 self.source[..offset]
270 .bytes()
271 .rev()
272 .find(|byte| !matches!(byte, b' ' | b'\t'))
273 .is_none_or(|byte| byte == b'\n')
274 }
275
276 fn line_end(&self, offset: usize) -> Option<usize> {
278 let bytes = self.source.as_bytes();
279 let mut i = offset;
280 while matches!(bytes.get(i), Some(b' ' | b'\t')) {
281 i += 1;
282 }
283 match bytes.get(i) {
284 Some(b'\n') => Some(i + 1),
285 Some(b'\r') if bytes.get(i + 1) == Some(&b'\n') => Some(i + 2),
286 None => Some(i),
287 _ => None,
288 }
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use rustlavel_core::Error;
296
297 fn tokens(source: &str) -> Vec<Token> {
298 tokenize("test", source).unwrap()
299 }
300
301 fn text_of(source: &str) -> String {
302 tokens(source)
303 .into_iter()
304 .filter_map(|token| match token {
305 Token::Text(text) => Some(text),
306 _ => None,
307 })
308 .collect()
309 }
310
311 #[test]
312 fn separates_text_from_echoes() {
313 assert_eq!(
314 tokens("<h1>{{ title }}</h1>"),
315 vec![
316 Token::Text("<h1>".into()),
317 Token::Echo { source: " title ".into(), offset: 6, escaped: true },
318 Token::Text("</h1>".into()),
319 ]
320 );
321 }
322
323 #[test]
324 fn a_raw_echo_is_marked_unescaped() {
325 assert_eq!(
326 tokens("{!! body !!}"),
327 vec![Token::Echo { source: " body ".into(), offset: 3, escaped: false }]
328 );
329 }
330
331 #[test]
332 fn comments_are_stripped_entirely() {
333 assert_eq!(text_of("a{{-- secret {{ x }} --}}b"), "ab");
334 }
335
336 #[test]
337 fn reads_a_directive_with_its_arguments() {
338 assert_eq!(
339 tokens("@if(a == \")\")x@endif"),
340 vec![
341 Token::Directive {
342 name: "if".into(),
343 arguments: "a == \")\"".into(),
344 arguments_offset: 4,
345 offset: 0,
346 },
347 Token::Text("x".into()),
348 Token::Directive {
349 name: "endif".into(),
350 arguments: String::new(),
351 arguments_offset: 20,
352 offset: 14,
353 },
354 ]
355 );
356 }
357
358 #[test]
359 fn an_unknown_at_sign_stays_text() {
360 assert_eq!(text_of("@media print { a { color: #000 } } ada@example.com"),
361 "@media print { a { color: #000 } } ada@example.com");
362 assert_eq!(text_of("@@if(x)"), "@if(x)");
363 assert_eq!(text_of("@{{ vue }}"), "{{ vue }}");
364 }
365
366 #[test]
367 fn a_structural_directive_alone_on_a_line_takes_the_line_with_it() {
368 let source = "<ul>\n @foreach(items as item)\n <li></li>\n @endforeach\n</ul>\n";
369
370 assert_eq!(text_of(source), "<ul>\n <li></li>\n</ul>\n");
371 }
372
373 #[test]
374 fn an_inline_directive_keeps_the_surrounding_spacing() {
375 assert_eq!(text_of("a @if(x)b@endif c"), "a b c");
376 }
377
378 #[test]
379 fn php_blocks_are_rejected_by_name() {
380 let error = tokenize("test", "<p>\n@php $x = 1; @endphp\n").unwrap_err();
381
382 match error {
383 Error::Template { line, message, .. } => {
384 assert_eq!(line, 2);
385 assert!(message.contains("@php"), "{message}");
386 }
387 other => panic!("expected a template error, got {other:?}"),
388 }
389 }
390
391 #[test]
392 fn an_unclosed_echo_is_reported_where_it_opened() {
393 match tokenize("test", "ok\n{{ title\n").unwrap_err() {
394 Error::Template { line, column, .. } => assert_eq!((line, column), (2, 1)),
395 other => panic!("expected a template error, got {other:?}"),
396 }
397 }
398}