1use crate::token::{Span, Token, TokenKind};
4use std::iter::Peekable;
5use std::str::CharIndices;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct LexError {
10 pub message: String,
11 pub span: Span,
12}
13
14impl LexError {
15 fn new(message: impl Into<String>, span: Span) -> Self {
16 Self {
17 message: message.into(),
18 span,
19 }
20 }
21}
22
23pub struct Lexer<'src> {
35 source: &'src str,
36 chars: Peekable<CharIndices<'src>>,
37 after_first_token: bool,
39 pending_newline: bool,
41 pending_newline_start: usize,
43 eof_emitted: bool,
45}
46
47impl<'src> Lexer<'src> {
48 pub fn new(source: &'src str) -> Self {
50 Self {
51 source,
52 chars: source.char_indices().peekable(),
53 after_first_token: false,
54 pending_newline: false,
55 pending_newline_start: 0,
56 eof_emitted: false,
57 }
58 }
59
60 fn peek_char(&mut self) -> Option<char> {
61 self.chars.peek().map(|&(_, c)| c)
62 }
63
64 fn skip_trivia(&mut self) {
68 loop {
69 match self.peek_char() {
70 Some(' ') | Some('\t') | Some('\r') => {
71 self.chars.next();
72 }
73 Some('\n') => {
74 let (idx, _) = self.chars.next().unwrap();
75 if !self.pending_newline {
76 self.pending_newline = true;
77 self.pending_newline_start = idx;
78 }
79 }
80 Some('#') => {
81 while let Some(c) = self.peek_char() {
82 if c == '\n' {
83 break;
84 }
85 self.chars.next();
86 }
87 }
88 _ => break,
89 }
90 }
91 }
92
93 fn scan_ident(&mut self, start: usize) -> Token {
97 let mut end = start;
98 while let Some(&(idx, c)) = self.chars.peek() {
99 if c.is_alphanumeric() || c == '_' || c == '-' {
100 end = idx + c.len_utf8();
101 self.chars.next();
102 } else {
103 break;
104 }
105 }
106 let text = &self.source[start..end];
107 let kind = keyword_kind(text).unwrap_or_else(|| TokenKind::Ident(text.to_string()));
108 Token::new(kind, Span::new(start, end))
109 }
110
111 fn scan_string(&mut self, start: usize, quote: char) -> Result<Token, LexError> {
112 self.chars.next(); let mut content = String::new();
114 loop {
115 match self.chars.next() {
116 None => {
117 return Err(LexError::new(
118 "unterminated string literal",
119 Span::new(start, self.source.len()),
120 ));
121 }
122 Some((idx, c)) if c == quote => {
123 let end = idx + c.len_utf8();
124 return Ok(Token::new(TokenKind::Str(content), Span::new(start, end)));
125 }
126 Some((_, '\\')) => match self.chars.next() {
127 None => {
128 return Err(LexError::new(
129 "unterminated string literal",
130 Span::new(start, self.source.len()),
131 ));
132 }
133 Some((idx, escaped)) => {
134 let decoded = match escaped {
135 '"' => '"',
136 '\\' => '\\',
137 'n' => '\n',
138 't' => '\t',
139 other => {
140 return Err(LexError::new(
141 format!("unknown escape sequence '\\{other}'"),
142 Span::new(idx - 1, idx + other.len_utf8()),
143 ));
144 }
145 };
146 content.push(decoded);
147 }
148 },
149 Some((_, c)) => content.push(c),
150 }
151 }
152 }
153
154 fn scan_punct(&mut self, start: usize, first: char) -> Result<Token, LexError> {
159 self.chars.next(); let single = |kind: TokenKind| Ok(Token::new(kind, Span::new(start, start + 1)));
161 match first {
162 '{' => single(TokenKind::LBrace),
163 '}' => single(TokenKind::RBrace),
164 '[' => single(TokenKind::LBracket),
165 ']' => single(TokenKind::RBracket),
166 '(' => single(TokenKind::LParen),
167 ')' => single(TokenKind::RParen),
168 ',' => single(TokenKind::Comma),
169 ':' => single(TokenKind::Colon),
170 '+' => single(TokenKind::Plus),
171 '.' => single(TokenKind::Dot),
172 '=' => {
173 if self.peek_char() == Some('=') {
174 self.chars.next();
175 Ok(Token::new(TokenKind::EqEq, Span::new(start, start + 2)))
176 } else {
177 single(TokenKind::Eq)
178 }
179 }
180 '!' => {
181 if self.peek_char() == Some('=') {
182 self.chars.next();
183 Ok(Token::new(TokenKind::NotEq, Span::new(start, start + 2)))
184 } else {
185 Err(LexError::new(
186 "unexpected character '!'",
187 Span::new(start, start + 1),
188 ))
189 }
190 }
191 '&' => {
192 if self.peek_char() == Some('&') {
193 self.chars.next();
194 Ok(Token::new(TokenKind::AndAnd, Span::new(start, start + 2)))
195 } else {
196 Err(LexError::new(
197 "unexpected character '&'",
198 Span::new(start, start + 1),
199 ))
200 }
201 }
202 '|' => {
203 if self.peek_char() == Some('|') {
204 self.chars.next();
205 Ok(Token::new(TokenKind::OrOr, Span::new(start, start + 2)))
206 } else {
207 Err(LexError::new(
208 "unexpected character '|'",
209 Span::new(start, start + 1),
210 ))
211 }
212 }
213 other => Err(LexError::new(
214 format!("unexpected character '{other}'"),
215 Span::new(start, start + other.len_utf8()),
216 )),
217 }
218 }
219}
220
221fn keyword_kind(text: &str) -> Option<TokenKind> {
223 Some(match text {
224 "version" => TokenKind::KwVersion,
225 "import" => TokenKind::KwImport,
226 "as" => TokenKind::KwAs,
227 "let" => TokenKind::KwLet,
228 "default" => TokenKind::KwDefault,
229 "beam" => TokenKind::KwBeam,
230 "hook" => TokenKind::KwHook,
231 "if" => TokenKind::KwIf,
232 "then" => TokenKind::KwThen,
233 "else" => TokenKind::KwElse,
234 "true" => TokenKind::KwTrue,
235 "false" => TokenKind::KwFalse,
236 _ => return None,
237 })
238}
239
240impl<'src> Iterator for Lexer<'src> {
241 type Item = Result<Token, LexError>;
242
243 fn next(&mut self) -> Option<Self::Item> {
244 if self.eof_emitted {
245 return None;
246 }
247
248 self.skip_trivia();
249
250 let Some(&(start, ch)) = self.chars.peek() else {
254 self.pending_newline = false;
255 self.eof_emitted = true;
256 let pos = self.source.len();
257 return Some(Ok(Token::new(TokenKind::Eof, Span::new(pos, pos))));
258 };
259
260 if self.pending_newline && self.after_first_token {
261 self.pending_newline = false;
262 let start = self.pending_newline_start;
263 return Some(Ok(Token::new(
264 TokenKind::Newline,
265 Span::new(start, start + 1),
266 )));
267 }
268 self.pending_newline = false;
269
270 self.after_first_token = true;
271
272 if ch == '"' || ch == '\'' {
273 return Some(self.scan_string(start, ch));
274 }
275 if ch.is_alphabetic() || ch == '_' {
276 return Some(Ok(self.scan_ident(start)));
277 }
278 Some(self.scan_punct(start, ch))
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use crate::Lexer;
285 use crate::TokenKind;
286
287 fn lex_kinds(source: &str) -> Vec<TokenKind> {
290 Lexer::new(source)
291 .map(|result| result.expect("unexpected lex error"))
292 .map(|token| token.kind)
293 .collect()
294 }
295
296 #[test]
297 fn lexes_beam_header() {
298 let tokens: Vec<TokenKind> = lex_kinds("beam build {");
299 assert_eq!(
300 tokens,
301 vec![
302 TokenKind::KwBeam,
303 TokenKind::Ident("build".into()),
304 TokenKind::LBrace,
305 TokenKind::Eof,
306 ]
307 );
308 }
309
310 #[test]
311 fn lexes_string_and_operators() {
312 let tokens = lex_kinds(r#"let x = env("PROFILE") == "release""#);
313 assert!(tokens.contains(&TokenKind::Str("PROFILE".into())));
314 assert!(tokens.contains(&TokenKind::EqEq));
315 }
316
317 #[test]
318 fn reports_unterminated_string_with_span() {
319 let err = Lexer::new("run \"oops").find_map(Result::err).unwrap();
320 assert_eq!(err.span.start, 4);
321 assert!(err.message.contains("unterminated string"));
322 }
323
324 #[test]
325 fn skips_comments_and_collapses_newlines() {
326 let tokens = lex_kinds("# comment\n\n\nbeam x {\n}");
327 let newlines = tokens.iter().filter(|t| **t == TokenKind::Newline).count();
328 assert_eq!(newlines, 1);
329 }
330
331 #[test]
332 fn no_newline_token_before_eof() {
333 let tokens = lex_kinds("beam x {\n}\n\n");
336 assert_eq!(tokens.last(), Some(&TokenKind::Eof));
337 assert_eq!(tokens[tokens.len() - 2], TokenKind::RBrace);
338 }
339
340 #[test]
341 fn lexes_all_keywords() {
342 let tokens = lex_kinds("version import as let default beam if then else true false hook");
343 assert_eq!(
344 tokens,
345 vec![
346 TokenKind::KwVersion,
347 TokenKind::KwImport,
348 TokenKind::KwAs,
349 TokenKind::KwLet,
350 TokenKind::KwDefault,
351 TokenKind::KwBeam,
352 TokenKind::KwIf,
353 TokenKind::KwThen,
354 TokenKind::KwElse,
355 TokenKind::KwTrue,
356 TokenKind::KwFalse,
357 TokenKind::KwHook,
358 TokenKind::Eof,
359 ]
360 );
361 }
362
363 #[test]
364 fn lexes_both_string_delimiters() {
365 let tokens = lex_kinds(r#""double" 'single'"#);
366 assert_eq!(
367 tokens,
368 vec![
369 TokenKind::Str("double".into()),
370 TokenKind::Str("single".into()),
371 TokenKind::Eof,
372 ]
373 );
374 }
375
376 #[test]
377 fn decodes_string_escapes() {
378 let tokens = lex_kinds(r#""a\"b\\c\nd\te""#);
379 assert_eq!(tokens[0], TokenKind::Str("a\"b\\c\nd\te".into()));
380 }
381
382 #[test]
383 fn string_passes_through_interpolation_braces_as_raw_text() {
384 let tokens = lex_kinds(r#""hello {name}""#);
385 assert_eq!(tokens[0], TokenKind::Str("hello {name}".into()));
386 }
387
388 #[test]
389 fn lexes_logical_operators_and_needs_colon_syntax() {
390 let tokens = lex_kinds("a && b || c != d api:build");
391 assert_eq!(
392 tokens,
393 vec![
394 TokenKind::Ident("a".into()),
395 TokenKind::AndAnd,
396 TokenKind::Ident("b".into()),
397 TokenKind::OrOr,
398 TokenKind::Ident("c".into()),
399 TokenKind::NotEq,
400 TokenKind::Ident("d".into()),
401 TokenKind::Ident("api".into()),
402 TokenKind::Colon,
403 TokenKind::Ident("build".into()),
404 TokenKind::Eof,
405 ]
406 );
407 }
408
409 #[test]
410 fn reports_unexpected_character() {
411 let err = Lexer::new("beam @ {").find_map(Result::err).unwrap();
412 assert_eq!(err.span, crate::Span::new(5, 6));
413 assert!(err.message.contains('@'));
414 }
415
416 #[test]
417 fn token_spans_are_accurate_byte_offsets() {
418 let tokens: Vec<_> = Lexer::new("beam build")
419 .map(|r| r.expect("unexpected lex error"))
420 .collect();
421 assert_eq!(tokens[0].span, crate::Span::new(0, 4)); assert_eq!(tokens[1].span, crate::Span::new(5, 10)); }
424
425 #[test]
426 fn eof_span_is_at_end_of_source() {
427 let tokens: Vec<_> = Lexer::new("x")
428 .map(|r| r.expect("unexpected lex error"))
429 .collect();
430 assert_eq!(tokens.last().unwrap().span, crate::Span::new(1, 1));
431 }
432}