Skip to main content

harn_lexer/
lexer.rs

1use crate::token::*;
2use std::fmt;
3
4/// Lexer errors.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum LexerError {
7    UnexpectedCharacter(char, Span),
8    UnterminatedString(Span),
9    UnterminatedBlockComment(Span),
10    /// An integer literal whose magnitude does not fit in an `i64`. Reported
11    /// instead of silently degrading to a lossy float.
12    IntegerLiteralOutOfRange(String, Span),
13}
14
15impl fmt::Display for LexerError {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            LexerError::UnexpectedCharacter(ch, span) => {
19                write!(f, "Unexpected character '{ch}' at {span}")
20            }
21            LexerError::UnterminatedString(span) => {
22                write!(f, "Unterminated string at {span}")
23            }
24            LexerError::UnterminatedBlockComment(span) => {
25                write!(f, "Unterminated block comment at {span}")
26            }
27            LexerError::IntegerLiteralOutOfRange(lit, span) => {
28                write!(
29                    f,
30                    "Integer literal `{lit}` is out of range for int (i64) at {span}"
31                )
32            }
33        }
34    }
35}
36
37impl std::error::Error for LexerError {}
38
39/// Character-by-character scanner producing tokens.
40pub struct Lexer {
41    source: Vec<char>,
42    pos: usize,
43    byte_pos: usize,
44    line: usize,
45    column: usize,
46}
47
48impl Lexer {
49    pub fn new(source: &str) -> Self {
50        Self {
51            source: source.chars().collect(),
52            pos: 0,
53            byte_pos: 0,
54            line: 1,
55            column: 1,
56        }
57    }
58
59    /// Create a lexer that starts counting from the given source position.
60    /// Useful for re-lexing interpolated expressions at their original location.
61    pub fn with_position(source: &str, line: usize, column: usize) -> Self {
62        Self {
63            source: source.chars().collect(),
64            pos: 0,
65            byte_pos: 0,
66            line,
67            column,
68        }
69    }
70
71    /// Tokenize source code, including comment tokens.
72    pub fn tokenize_with_comments(&mut self) -> Result<Vec<Token>, LexerError> {
73        self.tokenize_inner(true)
74    }
75
76    pub fn tokenize(&mut self) -> Result<Vec<Token>, LexerError> {
77        self.tokenize_inner(false)
78    }
79
80    fn tokenize_inner(&mut self, keep_comments: bool) -> Result<Vec<Token>, LexerError> {
81        let mut tokens = Vec::new();
82
83        // Skip a `#!` shebang line if present at the very start of the file.
84        // Only valid at byte offset 0 — anywhere else, `#` is still an error.
85        if self.pos == 0 && self.source.starts_with(&['#', '!']) {
86            while self.pos < self.source.len() && self.source[self.pos] != '\n' {
87                self.advance();
88            }
89        }
90
91        while self.pos < self.source.len() {
92            let ch = self.source[self.pos];
93
94            if ch == ' ' || ch == '\t' || ch == '\r' {
95                self.advance();
96                continue;
97            }
98
99            // Backslash immediately before newline joins lines without emitting a Newline token.
100            if ch == '\\' && self.peek() == Some('\n') {
101                self.advance();
102                self.advance();
103                self.line += 1;
104                self.column = 1;
105                continue;
106            }
107
108            if ch == '\n' {
109                let start = self.byte_pos;
110                tokens.push(Token::with_span(
111                    TokenKind::Newline,
112                    Span::with_offsets(start, start + 1, self.line, self.column),
113                ));
114                self.advance();
115                self.line += 1;
116                self.column = 1;
117                continue;
118            }
119
120            if ch == '/' {
121                if self.peek() == Some('/') {
122                    let tok = self.read_line_comment();
123                    if keep_comments {
124                        tokens.push(tok);
125                    }
126                    continue;
127                }
128                if self.peek() == Some('*') {
129                    let tok = self.read_block_comment()?;
130                    if keep_comments {
131                        tokens.push(tok);
132                    }
133                    continue;
134                }
135            }
136
137            if ch == 'r' && self.peek() == Some('"') {
138                tokens.push(self.read_raw_string()?);
139                continue;
140            }
141
142            // Hashed raw string: r#"..."#, r##"..."##, etc. The `#` run lets
143            // the body hold literal `"` without escaping — the close is the
144            // first `"` followed by the matching number of `#`. Only treated
145            // as a raw string when the `#` run is actually followed by `"`;
146            // otherwise we fall through (no `r#`-identifier syntax in Harn).
147            if ch == 'r' && self.peek() == Some('#') {
148                if let Some(hashes) = self.raw_hash_count() {
149                    tokens.push(self.read_raw_string_hashed(hashes)?);
150                    continue;
151                }
152            }
153
154            if ch == '"' {
155                tokens.push(self.read_string()?);
156                continue;
157            }
158
159            if ch.is_ascii_digit() {
160                tokens.push(self.read_number()?);
161                continue;
162            }
163
164            if ch.is_alphabetic() || ch == '_' {
165                tokens.push(self.read_identifier());
166                continue;
167            }
168
169            if let Some(tok) = self.try_two_char_op() {
170                tokens.push(tok);
171                continue;
172            }
173
174            if let Some(kind) = self.single_char_token(ch) {
175                let start = self.byte_pos;
176                let col = self.column;
177                self.advance();
178                tokens.push(Token::with_span(
179                    kind,
180                    Span::with_offsets(start, self.byte_pos, self.line, col),
181                ));
182                continue;
183            }
184
185            return Err(LexerError::UnexpectedCharacter(
186                ch,
187                Span::with_offsets(
188                    self.byte_pos,
189                    self.byte_pos + ch.len_utf8(),
190                    self.line,
191                    self.column,
192                ),
193            ));
194        }
195
196        tokens.push(self.token(TokenKind::Eof));
197        Ok(tokens)
198    }
199
200    fn peek(&self) -> Option<char> {
201        self.source.get(self.pos + 1).copied()
202    }
203
204    fn advance(&mut self) {
205        if self.pos < self.source.len() {
206            self.byte_pos += self.source[self.pos].len_utf8();
207        }
208        self.pos += 1;
209        self.column += 1;
210    }
211
212    fn token(&self, kind: TokenKind) -> Token {
213        Token::with_span(
214            kind,
215            Span::with_offsets(self.byte_pos, self.byte_pos, self.line, self.column),
216        )
217    }
218
219    fn read_line_comment(&mut self) -> Token {
220        let start_byte = self.byte_pos;
221        let start_col = self.column;
222        let start_line = self.line;
223        self.advance();
224        self.advance();
225        // `///foo` is a doc comment, but `////foo` (a separator bar) is not.
226        let is_doc = self.source.get(self.pos).copied() == Some('/')
227            && self.source.get(self.pos + 1).copied() != Some('/');
228        if is_doc {
229            self.advance();
230        }
231        let mut text = String::new();
232        while self.pos < self.source.len() && self.source[self.pos] != '\n' {
233            text.push(self.source[self.pos]);
234            self.advance();
235        }
236        Token::with_span(
237            TokenKind::LineComment { text, is_doc },
238            Span::with_offsets(start_byte, self.byte_pos, start_line, start_col),
239        )
240    }
241
242    fn read_block_comment(&mut self) -> Result<Token, LexerError> {
243        let start_byte = self.byte_pos;
244        let start = Span::with_offsets(self.byte_pos, self.byte_pos, self.line, self.column);
245        self.advance();
246        self.advance();
247        // `/** ... */` is a doc comment, but `/*** */` and `/**/` are not.
248        let is_doc = self.source.get(self.pos).copied() == Some('*')
249            && self.source.get(self.pos + 1).copied() != Some('*')
250            && self.source.get(self.pos + 1).copied() != Some('/');
251        if is_doc {
252            self.advance();
253        }
254        let mut text = String::new();
255        let mut depth = 1;
256        while self.pos < self.source.len() && depth > 0 {
257            if self.source[self.pos] == '/' && self.peek() == Some('*') {
258                depth += 1;
259                text.push('/');
260                text.push('*');
261                self.advance();
262                self.advance();
263            } else if self.source[self.pos] == '*' && self.peek() == Some('/') {
264                depth -= 1;
265                if depth > 0 {
266                    text.push('*');
267                    text.push('/');
268                }
269                self.advance();
270                self.advance();
271            } else if self.source[self.pos] == '\n' {
272                text.push('\n');
273                self.byte_pos += self.source[self.pos].len_utf8();
274                self.line += 1;
275                self.column = 1;
276                self.pos += 1;
277            } else {
278                text.push(self.source[self.pos]);
279                self.advance();
280            }
281        }
282        if depth > 0 {
283            return Err(LexerError::UnterminatedBlockComment(start));
284        }
285        // The span must point at the opening `/*` (line/column of `start`),
286        // with `end_line` at the closing `*/` — mirroring how multi-line
287        // strings record their span. Using `self.line` for the start line
288        // here would report the comment's *end* line, which misplaces
289        // multi-line block comments for every span consumer (`harn fmt`,
290        // the LSP, diagnostics).
291        let mut span = Span::with_offsets(start_byte, self.byte_pos, start.line, start.column);
292        span.end_line = self.line;
293        Ok(Token::with_span(
294            TokenKind::BlockComment { text, is_doc },
295            span,
296        ))
297    }
298
299    /// Capture the raw source text of an interpolation hole `${ ... }`.
300    ///
301    /// Precondition: the lexer is positioned on the `$` of `${`. This consumes
302    /// the opening `${`, the hole's expression text, and the matching closing
303    /// `}`, returning the captured expression along with the line/column where
304    /// it began (used to anchor diagnostics when the hole is re-lexed).
305    ///
306    /// The scan is string-literal aware: a `}` or `{` that appears inside a
307    /// nested `"..."` string literal does not change brace depth, and a `\`
308    /// inside such a literal escapes the next character (so `\"` does not close
309    /// the nested string). This lets a hole contain string literals with braces
310    /// or quotes, e.g. `${ items["a}b"] }` or `${ x ?? "default" }`, instead of
311    /// terminating early at the first inner `}`.
312    fn capture_interpolation_expr(
313        &mut self,
314        start: Span,
315    ) -> Result<(String, usize, usize), LexerError> {
316        self.advance(); // consume '$'
317        self.advance(); // consume '{'
318        let expr_line = self.line;
319        let expr_col = self.column;
320        let mut depth = 1usize;
321        let mut expr = String::new();
322        let mut in_string = false;
323        while self.pos < self.source.len() && depth > 0 {
324            let ch = self.source[self.pos];
325            if in_string {
326                if ch == '\\' {
327                    // Preserve the backslash and the escaped char verbatim; an
328                    // escaped quote must not close the nested string literal.
329                    expr.push(ch);
330                    self.advance();
331                    if self.pos >= self.source.len() {
332                        return Err(LexerError::UnterminatedString(start));
333                    }
334                    let escaped = self.source[self.pos];
335                    if escaped == '\n' {
336                        self.line += 1;
337                        self.column = 0; // advance() restores column to 1
338                    }
339                    expr.push(escaped);
340                    self.advance();
341                    continue;
342                }
343                if ch == '"' {
344                    in_string = false;
345                }
346            } else {
347                match ch {
348                    // A backslash is never valid in expression position. The
349                    // usual cause is escaping the quotes of a nested string
350                    // literal (`${x ?? \"y\"}`); inside an interpolation hole,
351                    // string literals use bare double quotes (`${x ?? "y"}`).
352                    // Report it precisely here rather than scanning to EOF and
353                    // surfacing a misleading "unterminated string".
354                    '\\' => {
355                        return Err(LexerError::UnexpectedCharacter(
356                            '\\',
357                            Span::with_offsets(
358                                self.byte_pos,
359                                self.byte_pos + 1,
360                                self.line,
361                                self.column,
362                            ),
363                        ));
364                    }
365                    '"' => in_string = true,
366                    '{' => depth += 1,
367                    '}' => {
368                        depth -= 1;
369                        if depth == 0 {
370                            break;
371                        }
372                    }
373                    _ => {}
374                }
375            }
376            if ch == '\n' {
377                self.line += 1;
378                self.column = 0; // advance() restores column to 1
379            }
380            expr.push(ch);
381            self.advance();
382        }
383        if self.pos >= self.source.len() {
384            return Err(LexerError::UnterminatedString(start));
385        }
386        if expr.trim().is_empty() {
387            return Err(LexerError::UnexpectedCharacter(
388                '}',
389                Span::with_offsets(self.byte_pos, self.byte_pos + 1, self.line, self.column),
390            ));
391        }
392        self.advance(); // consume closing '}'
393        Ok((expr, expr_line, expr_col))
394    }
395
396    fn read_string(&mut self) -> Result<Token, LexerError> {
397        let start_byte = self.byte_pos;
398        let start = Span::with_offsets(start_byte, start_byte, self.line, self.column);
399
400        if self.pos + 2 < self.source.len()
401            && self.source[self.pos + 1] == '"'
402            && self.source[self.pos + 2] == '"'
403        {
404            return self.read_multi_line_string(start_byte, start);
405        }
406
407        self.advance();
408
409        let mut value = String::new();
410        let mut segments: Vec<StringSegment> = Vec::new();
411        let mut has_interpolation = false;
412
413        while self.pos < self.source.len() {
414            let ch = self.source[self.pos];
415            if ch == '"' {
416                self.advance();
417                if has_interpolation {
418                    if !value.is_empty() {
419                        segments.push(StringSegment::Literal(value));
420                    }
421                    return Ok(Token::with_span(
422                        TokenKind::InterpolatedString(segments),
423                        Span::with_offsets(start_byte, self.byte_pos, start.line, start.column),
424                    ));
425                }
426                return Ok(Token::with_span(
427                    TokenKind::StringLiteral(value),
428                    Span::with_offsets(start_byte, self.byte_pos, start.line, start.column),
429                ));
430            }
431
432            if ch == '$' && self.peek() == Some('{') {
433                has_interpolation = true;
434                if !value.is_empty() {
435                    segments.push(StringSegment::Literal(std::mem::take(&mut value)));
436                }
437                let (expr, expr_line, expr_col) = self.capture_interpolation_expr(start)?;
438                segments.push(StringSegment::Expression(expr, expr_line, expr_col));
439                continue;
440            }
441
442            if ch == '\\' {
443                self.advance();
444                if self.pos >= self.source.len() {
445                    return Err(LexerError::UnterminatedString(start));
446                }
447                let escaped = self.source[self.pos];
448                match escaped {
449                    'n' => value.push('\n'),
450                    'r' => value.push('\r'),
451                    't' => value.push('\t'),
452                    '0' => value.push('\0'),
453                    '\\' => value.push('\\'),
454                    '"' => value.push('"'),
455                    '$' => value.push('$'),
456                    _ => {
457                        value.push('\\');
458                        value.push(escaped);
459                    }
460                }
461                self.advance();
462                continue;
463            }
464
465            if ch == '\n' {
466                return Err(LexerError::UnterminatedString(start));
467            }
468
469            value.push(ch);
470            self.advance();
471        }
472        Err(LexerError::UnterminatedString(start))
473    }
474
475    fn read_multi_line_string(
476        &mut self,
477        start_byte: usize,
478        start: Span,
479    ) -> Result<Token, LexerError> {
480        self.advance();
481        self.advance();
482        self.advance();
483
484        if self.pos < self.source.len() && self.source[self.pos] == '\n' {
485            self.advance();
486            self.line += 1;
487            self.column = 1;
488        }
489
490        let mut value = String::new();
491        let mut segments: Vec<StringSegment> = Vec::new();
492        let mut has_interpolation = false;
493
494        while self.pos < self.source.len() {
495            if self.source[self.pos] == '"'
496                && self.pos + 2 < self.source.len()
497                && self.source[self.pos + 1] == '"'
498                && self.source[self.pos + 2] == '"'
499            {
500                self.advance();
501                self.advance();
502                self.advance();
503                if has_interpolation {
504                    if !value.is_empty() {
505                        segments.push(StringSegment::Literal(std::mem::take(&mut value)));
506                    }
507                    // Strip the common indent across all literal segments together so
508                    // interpolation boundaries don't produce uneven dedenting.
509                    let full_text: String = segments
510                        .iter()
511                        .map(|seg| match seg {
512                            StringSegment::Literal(s) => s.as_str(),
513                            _ => "",
514                        })
515                        .collect();
516                    let indent = common_indent(&full_text);
517                    let segments = if indent > 0 {
518                        let stripped_segments = segments
519                            .into_iter()
520                            .map(|seg| match seg {
521                                StringSegment::Literal(s) => {
522                                    StringSegment::Literal(strip_indent(&s, indent))
523                                }
524                                other => other,
525                            })
526                            .collect();
527                        strip_trailing_newline_segments(stripped_segments)
528                    } else {
529                        strip_trailing_newline_segments(segments)
530                    };
531                    let mut span =
532                        Span::with_offsets(start_byte, self.byte_pos, start.line, start.column);
533                    span.end_line = self.line;
534                    return Ok(Token::with_span(
535                        TokenKind::InterpolatedString(segments),
536                        span,
537                    ));
538                }
539                let stripped = strip_common_indent(&value);
540                let mut span =
541                    Span::with_offsets(start_byte, self.byte_pos, start.line, start.column);
542                span.end_line = self.line;
543                return Ok(Token::with_span(TokenKind::StringLiteral(stripped), span));
544            }
545
546            if self.source[self.pos] == '$' && self.peek() == Some('{') {
547                has_interpolation = true;
548                if !value.is_empty() {
549                    segments.push(StringSegment::Literal(std::mem::take(&mut value)));
550                }
551                let (expr, expr_line, expr_col) = self.capture_interpolation_expr(start)?;
552                segments.push(StringSegment::Expression(expr, expr_line, expr_col));
553                continue;
554            }
555
556            if self.source[self.pos] == '\\'
557                && self.peek() == Some('$')
558                && self.source.get(self.pos + 2) == Some(&'{')
559            {
560                // Only an unpaired backslash escapes `${`; pairs remain literal text.
561                let preceding_backslashes =
562                    value.chars().rev().take_while(|ch| *ch == '\\').count();
563                if preceding_backslashes % 2 == 0 {
564                    self.advance();
565                    value.push('$');
566                    self.advance();
567                    continue;
568                }
569            }
570
571            if self.source[self.pos] == '\n' {
572                value.push('\n');
573                self.advance();
574                self.line += 1;
575                self.column = 1;
576            } else {
577                value.push(self.source[self.pos]);
578                self.advance();
579            }
580        }
581        Err(LexerError::UnterminatedString(start))
582    }
583
584    /// If the current `r` is followed by one-or-more `#` and then a `"`,
585    /// return the `#` count (the opening delimiter width). Returns `None`
586    /// when the `#` run is not closed by a `"`, so the caller falls through
587    /// to ordinary tokenization. Pure lookahead — does not advance.
588    fn raw_hash_count(&self) -> Option<usize> {
589        let mut k = 1; // skip the leading `r`
590        let mut hashes = 0;
591        while self.source.get(self.pos + k) == Some(&'#') {
592            hashes += 1;
593            k += 1;
594        }
595        if hashes >= 1 && self.source.get(self.pos + k) == Some(&'"') {
596            Some(hashes)
597        } else {
598            None
599        }
600    }
601
602    /// Read a hashed raw string `r#"..."#` (with `hashes` `#` characters):
603    /// no escape processing, no interpolation. The body may contain `"`;
604    /// the literal ends at the first `"` followed by at least `hashes` `#`,
605    /// consuming exactly `hashes` of them (any extra `#` stay in the stream,
606    /// matching Rust's raw-string semantics). Like `r"..."`, the body may not
607    /// span a newline.
608    fn read_raw_string_hashed(&mut self, hashes: usize) -> Result<Token, LexerError> {
609        let start_byte = self.byte_pos;
610        let start = Span::with_offsets(start_byte, start_byte, self.line, self.column);
611        // Consume `r`, the `#` run, and the opening `"`.
612        self.advance(); // r
613        for _ in 0..hashes {
614            self.advance();
615        }
616        self.advance(); // opening quote
617
618        let mut value = String::new();
619        while self.pos < self.source.len() {
620            let ch = self.source[self.pos];
621            if ch == '"' {
622                // A closing quote only ends the literal if followed by the
623                // matching `#` run; otherwise it is a literal quote.
624                let closed = (1..=hashes).all(|k| self.source.get(self.pos + k) == Some(&'#'));
625                if closed {
626                    self.advance(); // closing quote
627                    for _ in 0..hashes {
628                        self.advance();
629                    }
630                    return Ok(Token::with_span(
631                        TokenKind::RawStringLiteral(value),
632                        Span::with_offsets(start_byte, self.byte_pos, start.line, start.column),
633                    ));
634                }
635            }
636            if ch == '\n' {
637                return Err(LexerError::UnterminatedString(start));
638            }
639            value.push(ch);
640            self.advance();
641        }
642        Err(LexerError::UnterminatedString(start))
643    }
644
645    /// Read a raw string `r"..."`: no escape processing, no interpolation.
646    fn read_raw_string(&mut self) -> Result<Token, LexerError> {
647        let start_byte = self.byte_pos;
648        let start = Span::with_offsets(start_byte, start_byte, self.line, self.column);
649        self.advance();
650        self.advance();
651
652        let mut value = String::new();
653        while self.pos < self.source.len() {
654            let ch = self.source[self.pos];
655            if ch == '"' {
656                self.advance();
657                return Ok(Token::with_span(
658                    TokenKind::RawStringLiteral(value),
659                    Span::with_offsets(start_byte, self.byte_pos, start.line, start.column),
660                ));
661            }
662            if ch == '\n' {
663                return Err(LexerError::UnterminatedString(start));
664            }
665            value.push(ch);
666            self.advance();
667        }
668        Err(LexerError::UnterminatedString(start))
669    }
670
671    fn read_number(&mut self) -> Result<Token, LexerError> {
672        let start_byte = self.byte_pos;
673        let start_col = self.column;
674        let mut num_str = String::new();
675        let mut is_float = false;
676
677        while self.pos < self.source.len()
678            && (self.source[self.pos].is_ascii_digit() || self.source[self.pos] == '.')
679        {
680            if self.source[self.pos] == '.' {
681                if is_float {
682                    break;
683                }
684                // Disambiguate `42.method` (method access) from `42.5` (float literal).
685                if let Some(next) = self.source.get(self.pos + 1) {
686                    if !next.is_ascii_digit() {
687                        break;
688                    }
689                } else {
690                    break;
691                }
692                is_float = true;
693            }
694            num_str.push(self.source[self.pos]);
695            self.advance();
696        }
697
698        if !is_float {
699            if let Some(ms) = self.try_duration_suffix(&num_str) {
700                return Ok(Token::with_span(
701                    TokenKind::DurationLiteral(ms),
702                    Span::with_offsets(start_byte, self.byte_pos, self.line, start_col),
703                ));
704            }
705        }
706
707        let span = Span::with_offsets(start_byte, self.byte_pos, self.line, start_col);
708        if is_float {
709            let n: f64 = num_str.parse().unwrap_or(0.0);
710            Ok(Token::with_span(TokenKind::FloatLiteral(n), span))
711        } else {
712            match num_str.parse::<i64>() {
713                Ok(n) => Ok(Token::with_span(TokenKind::IntLiteral(n), span)),
714                Err(_) => {
715                    // An integer literal that does not fit in i64 is a hard
716                    // error, not a silent degrade to a lossy float: the spec
717                    // grammar is `int_literal ::= digit+` with no documented
718                    // float promotion, and silently widening loses both value
719                    // (distinct literals collapse onto the same f64) and type.
720                    // The sign is applied later by the parser, so the most
721                    // negative i64 must be written as e.g. `-9223372036854775807 - 1`.
722                    Err(LexerError::IntegerLiteralOutOfRange(num_str, span))
723                }
724            }
725        }
726    }
727
728    /// Parse a duration suffix (ms, s, m, h, d, w) after a number, returning milliseconds.
729    fn try_duration_suffix(&mut self, num_str: &str) -> Option<u64> {
730        let n: u64 = num_str.parse().ok()?;
731        if self.pos < self.source.len() {
732            let ch = self.source[self.pos];
733            if ch == 'm'
734                && self.source.get(self.pos + 1) == Some(&'s')
735                && is_duration_suffix_boundary(self.source.get(self.pos + 2))
736            {
737                self.advance();
738                self.advance();
739                return Some(n);
740            }
741            if ch == 's' && is_duration_suffix_boundary(self.source.get(self.pos + 1)) {
742                self.advance();
743                return Some(n.saturating_mul(1000));
744            }
745            if ch == 'm' && is_duration_suffix_boundary(self.source.get(self.pos + 1)) {
746                self.advance();
747                return Some(n.saturating_mul(60_000));
748            }
749            if ch == 'h' && is_duration_suffix_boundary(self.source.get(self.pos + 1)) {
750                self.advance();
751                return Some(n.saturating_mul(3_600_000));
752            }
753            if ch == 'd' && is_duration_suffix_boundary(self.source.get(self.pos + 1)) {
754                self.advance();
755                return Some(n.saturating_mul(86_400_000));
756            }
757            if ch == 'w' && is_duration_suffix_boundary(self.source.get(self.pos + 1)) {
758                self.advance();
759                return Some(n.saturating_mul(604_800_000));
760            }
761        }
762        None
763    }
764
765    fn read_identifier(&mut self) -> Token {
766        let start_byte = self.byte_pos;
767        let start_col = self.column;
768        let mut ident = String::new();
769
770        while self.pos < self.source.len() {
771            let ch = self.source[self.pos];
772            if ch.is_alphanumeric() || ch == '_' {
773                ident.push(ch);
774                self.advance();
775            } else {
776                break;
777            }
778        }
779
780        let kind = match ident.as_str() {
781            "pipeline" => TokenKind::Pipeline,
782            "extends" => TokenKind::Extends,
783            "override" => TokenKind::Override,
784            "let" => TokenKind::Let,
785            "const" => TokenKind::Const,
786            "var" => TokenKind::Var,
787            "if" => TokenKind::If,
788            "else" => TokenKind::Else,
789            "for" => TokenKind::For,
790            "in" => TokenKind::In,
791            "match" => TokenKind::Match,
792            "retry" => TokenKind::Retry,
793            "parallel" => TokenKind::Parallel,
794            "return" => TokenKind::Return,
795            "import" => TokenKind::Import,
796            "true" => TokenKind::True,
797            "false" => TokenKind::False,
798            "nil" => TokenKind::Nil,
799            "try" => TokenKind::Try,
800            "catch" => TokenKind::Catch,
801            "throw" => TokenKind::Throw,
802            "finally" => TokenKind::Finally,
803            "fn" => TokenKind::Fn,
804            "spawn" => TokenKind::Spawn,
805            "while" => TokenKind::While,
806            "type" => TokenKind::TypeKw,
807            "enum" => TokenKind::Enum,
808            "eval_pack" => TokenKind::EvalPack,
809            "struct" => TokenKind::Struct,
810            "interface" => TokenKind::Interface,
811            "emit" => TokenKind::Emit,
812            "pub" => TokenKind::Pub,
813            "from" => TokenKind::From,
814            "to" => TokenKind::To,
815            "tool" => TokenKind::Tool,
816            "exclusive" => TokenKind::Exclusive,
817            "guard" => TokenKind::Guard,
818            "require" => TokenKind::Require,
819            "deadline" => TokenKind::Deadline,
820            "defer" => TokenKind::Defer,
821            "yield" => TokenKind::Yield,
822            "mutex" => TokenKind::Mutex,
823            "break" => TokenKind::Break,
824            "continue" => TokenKind::Continue,
825            "select" => TokenKind::Select,
826            "impl" => TokenKind::Impl,
827            "skill" => TokenKind::Skill,
828            "request_approval" => TokenKind::RequestApproval,
829            "dual_control" => TokenKind::DualControl,
830            "ask_user" => TokenKind::AskUser,
831            "escalate_to" => TokenKind::EscalateTo,
832            _ => TokenKind::Identifier(ident),
833        };
834
835        Token::with_span(
836            kind,
837            Span::with_offsets(start_byte, self.byte_pos, self.line, start_col),
838        )
839    }
840
841    fn try_two_char_op(&mut self) -> Option<Token> {
842        if self.pos >= self.source.len() {
843            return None;
844        }
845        let ch = self.source[self.pos];
846        let next = self.peek()?;
847
848        let kind = match (ch, next) {
849            ('=', '=') => TokenKind::Eq,
850            ('!', '=') => TokenKind::Neq,
851            ('&', '&') => TokenKind::And,
852            ('|', '|') => TokenKind::Or,
853            ('|', '>') => TokenKind::Pipe,
854            ('?', '?') => TokenKind::NilCoal,
855            ('*', '*') => TokenKind::Pow,
856            ('?', '.') => TokenKind::QuestionDot,
857            ('-', '>') => TokenKind::Arrow,
858            ('-', '=') => TokenKind::MinusAssign,
859            ('+', '=') => TokenKind::PlusAssign,
860            ('*', '=') => TokenKind::StarAssign,
861            ('/', '=') => TokenKind::SlashAssign,
862            ('%', '=') => TokenKind::PercentAssign,
863            ('<', '=') => TokenKind::Lte,
864            ('>', '=') => TokenKind::Gte,
865            _ => return None,
866        };
867
868        let start_byte = self.byte_pos;
869        let col = self.column;
870        self.advance();
871        self.advance();
872        Some(Token::with_span(
873            kind,
874            Span::with_offsets(start_byte, self.byte_pos, self.line, col),
875        ))
876    }
877
878    fn single_char_token(&self, ch: char) -> Option<TokenKind> {
879        match ch {
880            '{' => Some(TokenKind::LBrace),
881            '}' => Some(TokenKind::RBrace),
882            '(' => Some(TokenKind::LParen),
883            ')' => Some(TokenKind::RParen),
884            '[' => Some(TokenKind::LBracket),
885            ']' => Some(TokenKind::RBracket),
886            ',' => Some(TokenKind::Comma),
887            ':' => Some(TokenKind::Colon),
888            ';' => Some(TokenKind::Semicolon),
889            '.' => Some(TokenKind::Dot),
890            '=' => Some(TokenKind::Assign),
891            '!' => Some(TokenKind::Not),
892            '+' => Some(TokenKind::Plus),
893            '-' => Some(TokenKind::Minus),
894            '*' => Some(TokenKind::Star),
895            '/' => Some(TokenKind::Slash),
896            '%' => Some(TokenKind::Percent),
897            '<' => Some(TokenKind::Lt),
898            '>' => Some(TokenKind::Gt),
899            '?' => Some(TokenKind::Question),
900            '|' => Some(TokenKind::Bar),
901            '&' => Some(TokenKind::Amp),
902            '@' => Some(TokenKind::At),
903            _ => None,
904        }
905    }
906}
907
908fn is_duration_suffix_boundary(ch: Option<&char>) -> bool {
909    ch.is_none_or(|c| !c.is_alphanumeric() && *c != '_')
910}
911
912/// Strip common leading whitespace from multi-line strings.
913fn strip_common_indent(text: &str) -> String {
914    let lines: Vec<&str> = text.split('\n').collect();
915    let content_lines: Vec<&&str> = lines.iter().filter(|l| !l.trim().is_empty()).collect();
916
917    if content_lines.is_empty() {
918        return text.to_string();
919    }
920
921    let min_indent = content_lines
922        .iter()
923        .map(|line| line.chars().take_while(|c| *c == ' ' || *c == '\t').count())
924        .min()
925        .unwrap_or(0);
926
927    if min_indent == 0 {
928        return text.strip_suffix('\n').unwrap_or(text).to_string();
929    }
930
931    let stripped: String = lines
932        .iter()
933        .map(|line| {
934            if line.trim().is_empty() {
935                ""
936            } else {
937                let skip = min_indent.min(line.len());
938                &line[skip..]
939            }
940        })
941        .collect::<Vec<&str>>()
942        .join("\n");
943
944    stripped.strip_suffix('\n').unwrap_or(&stripped).to_string()
945}
946
947/// Compute the common leading indent (spaces/tabs) across non-empty lines.
948fn common_indent(text: &str) -> usize {
949    text.split('\n')
950        .filter(|l| !l.trim().is_empty())
951        .map(|line| line.chars().take_while(|c| *c == ' ' || *c == '\t').count())
952        .min()
953        .unwrap_or(0)
954}
955
956/// Strip up to `n` leading whitespace characters from each line and remove trailing newline.
957fn strip_indent(text: &str, n: usize) -> String {
958    let lines: Vec<&str> = text.split('\n').collect();
959    let stripped: String = lines
960        .iter()
961        .map(|line| {
962            if line.trim().is_empty() {
963                ""
964            } else {
965                let ws = line.chars().take_while(|c| *c == ' ' || *c == '\t').count();
966                let skip = n.min(ws);
967                &line[skip..]
968            }
969        })
970        .collect::<Vec<&str>>()
971        .join("\n");
972    stripped.strip_suffix('\n').unwrap_or(&stripped).to_string()
973}
974
975/// Escape `value` so it round-trips as the body of a double-quoted Harn
976/// string literal: the lexer's escape set (`\n`, `\r`, `\t`, `\0`, `\\`,
977/// `\"`) plus `\$` before `{` so a value containing `${…}` renders as text
978/// instead of becoming live interpolation when the generated source is
979/// compiled. This is the single source of truth for code generators — the
980/// CLI's `harn try` scaffolding and the VM's composition/crystallize
981/// codegen had each grown a private copy that forgot `${`, letting a value
982/// like `${host_call(...)}` execute.
983pub fn escape_string_literal(value: &str) -> String {
984    let mut out = String::with_capacity(value.len());
985    let mut chars = value.chars().peekable();
986    while let Some(ch) = chars.next() {
987        match ch {
988            '\\' => out.push_str("\\\\"),
989            '"' => out.push_str("\\\""),
990            '\n' => out.push_str("\\n"),
991            '\r' => out.push_str("\\r"),
992            '\t' => out.push_str("\\t"),
993            '\0' => out.push_str("\\0"),
994            '$' if chars.peek() == Some(&'{') => out.push_str("\\$"),
995            _ => out.push(ch),
996        }
997    }
998    out
999}
1000
1001/// Remove a trailing-newline-only literal segment (for multiline strings
1002/// where the last segment before `"""` is just whitespace).
1003fn strip_trailing_newline_segments(mut segments: Vec<StringSegment>) -> Vec<StringSegment> {
1004    if let Some(StringSegment::Literal(s)) = segments.last() {
1005        if s.trim().is_empty() {
1006            segments.pop();
1007        }
1008    }
1009    segments
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015
1016    #[test]
1017    fn escape_string_literal_round_trips_through_the_lexer() {
1018        for original in [
1019            "plain text",
1020            "with \"quotes\" and \\backslash",
1021            "newline\ntab\tcr\r",
1022            "interpolation ${1 + 1} stays text",
1023            "already-escaped \\${x}",
1024            "plain $dollar and ${nested ${inner}}",
1025        ] {
1026            let literal = format!("\"{}\"", escape_string_literal(original));
1027            let mut lexer = Lexer::new(&literal);
1028            let tokens = lexer.tokenize().expect("escaped literal must lex");
1029            let TokenKind::StringLiteral(ref value) = tokens[0].kind else {
1030                panic!(
1031                    "expected a plain string token for {literal:?}, got {:?}",
1032                    tokens[0].kind
1033                );
1034            };
1035            assert_eq!(value, original, "round trip through {literal:?}");
1036        }
1037    }
1038
1039    #[test]
1040    fn shebang_at_offset_zero_is_skipped() {
1041        let src = "#!/usr/bin/env harn\nlet x = 1";
1042        let mut lexer = Lexer::new(src);
1043        let tokens = lexer.tokenize().expect("shebang should be skipped");
1044        // Expect: Newline, Let, Identifier(x), Eq, IntLiteral(1)
1045        assert_eq!(tokens[0].kind, TokenKind::Newline);
1046        assert_eq!(tokens[1].kind, TokenKind::Let);
1047        assert!(matches!(&tokens[2].kind, TokenKind::Identifier(n) if n == "x"));
1048    }
1049
1050    #[test]
1051    fn shebang_without_trailing_newline_is_skipped() {
1052        let src = "#!/usr/bin/env harn";
1053        let mut lexer = Lexer::new(src);
1054        let tokens = lexer.tokenize().expect("shebang at EOF should be skipped");
1055        // After the shebang there should be only the trailing EOF token.
1056        let non_eof: Vec<_> = tokens
1057            .iter()
1058            .filter(|t| !matches!(t.kind, TokenKind::Eof))
1059            .collect();
1060        assert!(
1061            non_eof.is_empty(),
1062            "expected only EOF after shebang-only file, got {non_eof:?}"
1063        );
1064    }
1065
1066    #[test]
1067    fn hash_in_middle_of_file_still_errors() {
1068        let src = "let x = 1\n# not a shebang\n";
1069        let mut lexer = Lexer::new(src);
1070        let result = lexer.tokenize();
1071        assert!(
1072            matches!(result, Err(LexerError::UnexpectedCharacter('#', _))),
1073            "got {result:?}"
1074        );
1075    }
1076
1077    #[test]
1078    fn test_keywords() {
1079        let mut lexer = Lexer::new("pipeline let var if else for in require");
1080        let tokens = lexer.tokenize().unwrap();
1081        assert_eq!(tokens[0].kind, TokenKind::Pipeline);
1082        assert_eq!(tokens[1].kind, TokenKind::Let);
1083        assert_eq!(tokens[2].kind, TokenKind::Var);
1084        assert_eq!(tokens[3].kind, TokenKind::If);
1085        assert_eq!(tokens[4].kind, TokenKind::Else);
1086        assert_eq!(tokens[5].kind, TokenKind::For);
1087        assert_eq!(tokens[6].kind, TokenKind::In);
1088        assert_eq!(tokens[7].kind, TokenKind::Require);
1089    }
1090
1091    #[test]
1092    fn test_keywords_const_covers_lexer() {
1093        // Every string in KEYWORDS must lex as a non-identifier token.
1094        // If this fails, either KEYWORDS has a stale entry or the lexer
1095        // match in `identifier_or_keyword` is missing an arm.
1096        for kw in KEYWORDS {
1097            let mut lexer = Lexer::new(kw);
1098            let tokens = lexer.tokenize().expect("lex keyword");
1099            let first = &tokens[0].kind;
1100            assert!(
1101                !matches!(first, TokenKind::Identifier(_)),
1102                "keyword `{kw}` lexes as Identifier — KEYWORDS const and lexer match are out of sync"
1103            );
1104        }
1105    }
1106
1107    #[test]
1108    fn test_parallel_keyword() {
1109        let mut lexer = Lexer::new("parallel defer");
1110        let tokens = lexer.tokenize().unwrap();
1111        assert_eq!(tokens[0].kind, TokenKind::Parallel);
1112        assert_eq!(tokens[1].kind, TokenKind::Defer);
1113    }
1114
1115    #[test]
1116    fn test_numbers() {
1117        let mut lexer = Lexer::new("42 3.14");
1118        let tokens = lexer.tokenize().unwrap();
1119        assert_eq!(tokens[0].kind, TokenKind::IntLiteral(42));
1120        #[allow(clippy::approx_constant)]
1121        let expected = 3.14;
1122        assert_eq!(tokens[1].kind, TokenKind::FloatLiteral(expected));
1123    }
1124
1125    #[test]
1126    fn test_int_literal_max_is_exact_and_overflow_is_an_error() {
1127        // i64::MAX lexes exactly as an int.
1128        let mut lexer = Lexer::new("9223372036854775807");
1129        let tokens = lexer.tokenize().unwrap();
1130        assert_eq!(tokens[0].kind, TokenKind::IntLiteral(i64::MAX));
1131
1132        // i64::MAX + 1 (and anything larger) is rejected, not silently widened
1133        // to a lossy float. The sign is applied by the parser, so the most
1134        // negative i64 is unreachable as a bare literal and overflows here too.
1135        for src in ["9223372036854775808", "99999999999999999999999"] {
1136            let mut lexer = Lexer::new(src);
1137            assert!(
1138                matches!(
1139                    lexer.tokenize(),
1140                    Err(LexerError::IntegerLiteralOutOfRange(lit, _)) if lit == src
1141                ),
1142                "expected out-of-range error for {src}"
1143            );
1144        }
1145
1146        // A float literal of the same magnitude is still fine.
1147        let mut lexer = Lexer::new("9223372036854775808.0");
1148        let tokens = lexer.tokenize().unwrap();
1149        assert!(matches!(tokens[0].kind, TokenKind::FloatLiteral(_)));
1150    }
1151
1152    #[test]
1153    fn test_duration_suffix_requires_identifier_boundary() {
1154        let mut lexer = Lexer::new("1ms 1msfoo 2h_task 3s.ok");
1155        let tokens = lexer.tokenize().unwrap();
1156        assert_eq!(tokens[0].kind, TokenKind::DurationLiteral(1));
1157        assert_eq!(tokens[1].kind, TokenKind::IntLiteral(1));
1158        assert!(matches!(&tokens[2].kind, TokenKind::Identifier(name) if name == "msfoo"));
1159        assert_eq!(tokens[3].kind, TokenKind::IntLiteral(2));
1160        assert!(matches!(&tokens[4].kind, TokenKind::Identifier(name) if name == "h_task"));
1161        assert_eq!(tokens[5].kind, TokenKind::DurationLiteral(3000));
1162        assert_eq!(tokens[6].kind, TokenKind::Dot);
1163    }
1164
1165    #[test]
1166    fn test_duration_suffix_overflow_saturates() {
1167        let mut lexer = Lexer::new("18446744073709551615w");
1168        let tokens = lexer.tokenize().unwrap();
1169        assert_eq!(tokens[0].kind, TokenKind::DurationLiteral(u64::MAX));
1170    }
1171
1172    #[test]
1173    fn test_string() {
1174        let mut lexer = Lexer::new(r#""hello world""#);
1175        let tokens = lexer.tokenize().unwrap();
1176        assert_eq!(
1177            tokens[0].kind,
1178            TokenKind::StringLiteral("hello world".into())
1179        );
1180    }
1181
1182    #[test]
1183    fn test_interpolated_string() {
1184        let mut lexer = Lexer::new(r#""hello ${name}!""#);
1185        let tokens = lexer.tokenize().unwrap();
1186        if let TokenKind::InterpolatedString(segs) = &tokens[0].kind {
1187            assert_eq!(segs.len(), 3);
1188            assert_eq!(segs[0], StringSegment::Literal("hello ".into()));
1189            assert!(matches!(&segs[1], StringSegment::Expression(e, _, _) if e == "name"));
1190            assert_eq!(segs[2], StringSegment::Literal("!".into()));
1191        } else {
1192            panic!("Expected interpolated string");
1193        }
1194    }
1195
1196    /// Returns the captured text of the single interpolation hole in `src`.
1197    fn single_interpolation_expr(src: &str) -> String {
1198        let mut lexer = Lexer::new(src);
1199        let tokens = lexer.tokenize().unwrap();
1200        match &tokens[0].kind {
1201            TokenKind::InterpolatedString(segs) => segs
1202                .iter()
1203                .find_map(|s| match s {
1204                    StringSegment::Expression(e, _, _) => Some(e.clone()),
1205                    _ => None,
1206                })
1207                .expect("expected an interpolation expression segment"),
1208            other => panic!("expected interpolated string, got {other:?}"),
1209        }
1210    }
1211
1212    #[test]
1213    fn test_interpolation_capture_is_string_literal_aware() {
1214        // A `}` (or `{`) inside a nested string literal must not end the hole.
1215        assert_eq!(
1216            single_interpolation_expr(r#""${x ?? "a}b"}""#),
1217            r#"x ?? "a}b""#
1218        );
1219        assert_eq!(single_interpolation_expr(r#""${f("}")}""#), r#"f("}")"#);
1220        assert_eq!(
1221            single_interpolation_expr(r#""${items["a}b"]}""#),
1222            r#"items["a}b"]"#
1223        );
1224        // A `\"` inside the nested string is preserved verbatim so it does not
1225        // close the literal early.
1226        assert_eq!(
1227            single_interpolation_expr(r#""${x ?? "a\"b"}""#),
1228            r#"x ?? "a\"b""#
1229        );
1230    }
1231
1232    #[test]
1233    fn test_interpolation_escaped_outer_quote_is_rejected() {
1234        // Escaping the quotes of a nested string literal (`${x ?? \"y\"}`) is a
1235        // common mistake: inside an interpolation hole, string literals use bare
1236        // double quotes. A backslash is never valid in expression position, so
1237        // it is reported precisely at the backslash rather than scanning to EOF.
1238        let mut lexer = Lexer::new(r#""${x ?? \"y\"}""#);
1239        assert!(matches!(
1240            lexer.tokenize(),
1241            Err(LexerError::UnexpectedCharacter('\\', _))
1242        ));
1243    }
1244
1245    #[test]
1246    fn test_empty_interpolation_rejected_in_single_and_multiline_strings() {
1247        let mut single = Lexer::new(r#""hello ${}""#);
1248        assert!(matches!(
1249            single.tokenize(),
1250            Err(LexerError::UnexpectedCharacter('}', _))
1251        ));
1252
1253        let mut multiline = Lexer::new("\"\"\"\nhello ${}\n\"\"\"");
1254        assert!(matches!(
1255            multiline.tokenize(),
1256            Err(LexerError::UnexpectedCharacter('}', _))
1257        ));
1258    }
1259
1260    #[test]
1261    fn test_multiline_string_escaped_dollar_before_interpolation() {
1262        let mut lexer = Lexer::new("\"\"\"\n  hi \\${VAR}\n  hello ${name}\n\"\"\"");
1263        let tokens = lexer.tokenize().unwrap();
1264        if let TokenKind::InterpolatedString(segs) = &tokens[0].kind {
1265            assert_eq!(segs.len(), 2);
1266            assert_eq!(segs[0], StringSegment::Literal("hi ${VAR}\nhello ".into()));
1267            assert!(matches!(&segs[1], StringSegment::Expression(e, _, _) if e == "name"));
1268        } else {
1269            panic!("Expected interpolated string");
1270        }
1271    }
1272
1273    #[test]
1274    fn test_multiline_string_escaped_dollar_without_interpolation() {
1275        let mut lexer = Lexer::new("\"\"\"\n  hi \\${VAR}\n\"\"\"");
1276        let tokens = lexer.tokenize().unwrap();
1277        assert_eq!(tokens[0].kind, TokenKind::StringLiteral("hi ${VAR}".into()));
1278    }
1279
1280    #[test]
1281    fn test_multiline_string_preserves_non_interpolation_dollar_escape() {
1282        let mut lexer = Lexer::new("\"\"\"\n  echo \\$PATH\n\"\"\"");
1283        let tokens = lexer.tokenize().unwrap();
1284        assert_eq!(
1285            tokens[0].kind,
1286            TokenKind::StringLiteral("echo \\$PATH".into())
1287        );
1288    }
1289
1290    #[test]
1291    fn test_interpolated_string_multiline_expression_tracks_lines() {
1292        // Regression: `${...}` inside a single-line string can itself span
1293        // multiple lines (e.g. `${render(\n  "x",\n  {k: v},\n)}`). The
1294        // lexer used to consume those inner newlines without incrementing
1295        // the line counter, so every token after the string reported a
1296        // line number too low — by the number of newlines consumed inside
1297        // the interpolation. Downstream lint spans pointed to wrong lines.
1298        let src = "let x = \"${render(\n  \"a\",\n  b,\n)}\"\nlet y = 1\n";
1299        let mut lexer = Lexer::new(src);
1300        let tokens = lexer.tokenize().unwrap();
1301        // `let y` is on line 5 of the source.
1302        let let_y = tokens
1303            .iter()
1304            .skip(1) // the first `let` at line 1
1305            .find(|t| matches!(t.kind, TokenKind::Let))
1306            .expect("second `let`");
1307        assert_eq!(let_y.span.line, 5);
1308    }
1309
1310    #[test]
1311    fn test_two_char_operators() {
1312        let mut lexer = Lexer::new("== != && || |> ?? ** -> <= >=");
1313        let tokens = lexer.tokenize().unwrap();
1314        assert_eq!(tokens[0].kind, TokenKind::Eq);
1315        assert_eq!(tokens[1].kind, TokenKind::Neq);
1316        assert_eq!(tokens[2].kind, TokenKind::And);
1317        assert_eq!(tokens[3].kind, TokenKind::Or);
1318        assert_eq!(tokens[4].kind, TokenKind::Pipe);
1319        assert_eq!(tokens[5].kind, TokenKind::NilCoal);
1320        assert_eq!(tokens[6].kind, TokenKind::Pow);
1321        assert_eq!(tokens[7].kind, TokenKind::Arrow);
1322        assert_eq!(tokens[8].kind, TokenKind::Lte);
1323        assert_eq!(tokens[9].kind, TokenKind::Gte);
1324    }
1325
1326    #[test]
1327    fn test_block_comments() {
1328        let mut lexer = Lexer::new("/* outer /* nested */ still */ 42");
1329        let tokens = lexer.tokenize().unwrap();
1330        assert_eq!(tokens[0].kind, TokenKind::IntLiteral(42));
1331    }
1332
1333    #[test]
1334    fn test_multiline_block_comment_span_starts_at_open() {
1335        // A block comment that opens on line 2 and closes on line 4. Its span's
1336        // `line`/`column` must point at the opening `/*` (line 2), and `end_line`
1337        // at the closing `*/` (line 4) — matching how multi-line strings record
1338        // their span. Downstream consumers (`harn fmt`, the LSP) key comments by
1339        // `span.line`, so reporting the end line there misplaces the comment.
1340        let src = "let a = 1\n/* block\n   spanning\n   lines */\nlet b = 2";
1341        let mut lex = Lexer::new(src);
1342        let tokens = lex.tokenize_with_comments().unwrap();
1343        let block = tokens
1344            .iter()
1345            .find(|t| matches!(t.kind, TokenKind::BlockComment { .. }))
1346            .expect("block comment token");
1347        assert_eq!(block.span.line, 2, "start line should be the opening `/*`");
1348        assert_eq!(
1349            block.span.column, 1,
1350            "start column should be the `/*` column"
1351        );
1352        assert_eq!(
1353            block.span.end_line, 4,
1354            "end line should be the closing `*/`"
1355        );
1356    }
1357
1358    #[test]
1359    fn test_line_comment() {
1360        let mut lexer = Lexer::new("42 // comment\n43");
1361        let tokens = lexer.tokenize().unwrap();
1362        assert_eq!(tokens[0].kind, TokenKind::IntLiteral(42));
1363        assert_eq!(tokens[1].kind, TokenKind::Newline);
1364        assert_eq!(tokens[2].kind, TokenKind::IntLiteral(43));
1365    }
1366
1367    #[test]
1368    fn test_doc_line_comment_detection() {
1369        let cases = [
1370            ("// regular", false),
1371            ("/// doc", true),
1372            ("//// separator bar", false),
1373            ("///// also a bar", false),
1374            ("///", true), // empty doc comment
1375        ];
1376        for (src, expect_doc) in cases {
1377            let mut lex = Lexer::new(src);
1378            let tokens = lex.tokenize_with_comments().unwrap();
1379            match &tokens[0].kind {
1380                TokenKind::LineComment { is_doc, .. } => {
1381                    assert_eq!(
1382                        *is_doc, expect_doc,
1383                        "expected is_doc={expect_doc} for input {src:?}",
1384                    );
1385                }
1386                other => panic!("expected LineComment for {src:?}, got {other:?}"),
1387            }
1388        }
1389    }
1390
1391    #[test]
1392    fn test_doc_block_comment_detection() {
1393        let cases = [
1394            ("/* regular */", false),
1395            ("/** doc */", true),
1396            ("/*** not a doc */", false),
1397            ("/**/", false), // empty block comment, not a doc
1398        ];
1399        for (src, expect_doc) in cases {
1400            let mut lex = Lexer::new(src);
1401            let tokens = lex.tokenize_with_comments().unwrap();
1402            match &tokens[0].kind {
1403                TokenKind::BlockComment { is_doc, .. } => {
1404                    assert_eq!(
1405                        *is_doc, expect_doc,
1406                        "expected is_doc={expect_doc} for input {src:?}",
1407                    );
1408                }
1409                other => panic!("expected BlockComment for {src:?}, got {other:?}"),
1410            }
1411        }
1412    }
1413
1414    #[test]
1415    fn test_newlines() {
1416        let mut lexer = Lexer::new("a\nb");
1417        let tokens = lexer.tokenize().unwrap();
1418        assert_eq!(tokens[0].kind, TokenKind::Identifier("a".into()));
1419        assert_eq!(tokens[1].kind, TokenKind::Newline);
1420        assert_eq!(tokens[2].kind, TokenKind::Identifier("b".into()));
1421    }
1422
1423    #[test]
1424    fn test_backslash_continuation() {
1425        let mut lexer = Lexer::new("10 \\\n- 3");
1426        let tokens = lexer.tokenize().unwrap();
1427        assert_eq!(tokens[0].kind, TokenKind::IntLiteral(10));
1428        assert_eq!(tokens[1].kind, TokenKind::Minus);
1429        assert_eq!(tokens[2].kind, TokenKind::IntLiteral(3));
1430        // No Newline token between 10 and -: continuation joined them.
1431        assert_eq!(tokens.len(), 4);
1432    }
1433
1434    #[test]
1435    fn test_unexpected_character() {
1436        let mut lexer = Lexer::new("`");
1437        let err = lexer.tokenize().unwrap_err();
1438        assert!(matches!(err, LexerError::UnexpectedCharacter('`', _)));
1439    }
1440
1441    #[test]
1442    fn test_at_token() {
1443        let mut lexer = Lexer::new("@deprecated");
1444        let tokens = lexer.tokenize().unwrap();
1445        assert_eq!(tokens[0].kind, TokenKind::At);
1446        assert_eq!(tokens[1].kind, TokenKind::Identifier("deprecated".into()));
1447    }
1448
1449    #[test]
1450    fn test_unterminated_string() {
1451        let mut lexer = Lexer::new("\"unterminated");
1452        let err = lexer.tokenize().unwrap_err();
1453        assert!(matches!(err, LexerError::UnterminatedString(_)));
1454    }
1455
1456    #[test]
1457    fn test_escape_sequences() {
1458        let mut lexer = Lexer::new(r#""a\nb\t\\""#);
1459        let tokens = lexer.tokenize().unwrap();
1460        assert_eq!(tokens[0].kind, TokenKind::StringLiteral("a\nb\t\\".into()));
1461    }
1462
1463    #[test]
1464    fn test_escape_carriage_return_and_null() {
1465        let mut lexer = Lexer::new(r#""a\rb\0c""#);
1466        let tokens = lexer.tokenize().unwrap();
1467        assert_eq!(tokens[0].kind, TokenKind::StringLiteral("a\rb\0c".into()));
1468    }
1469
1470    #[test]
1471    fn test_number_then_dot_method() {
1472        let mut lexer = Lexer::new("42.method");
1473        let tokens = lexer.tokenize().unwrap();
1474        assert_eq!(tokens[0].kind, TokenKind::IntLiteral(42));
1475        assert_eq!(tokens[1].kind, TokenKind::Dot);
1476        assert_eq!(tokens[2].kind, TokenKind::Identifier("method".into()));
1477    }
1478
1479    #[test]
1480    fn test_hashed_raw_string_basic() {
1481        let mut lexer = Lexer::new("r#\"abc\"#");
1482        let tokens = lexer.tokenize().unwrap();
1483        assert_eq!(tokens[0].kind, TokenKind::RawStringLiteral("abc".into()));
1484    }
1485
1486    #[test]
1487    fn test_hashed_raw_string_embedded_quote() {
1488        // r#"a"b"# — the inner quote is not followed by `#`, so it's literal.
1489        let mut lexer = Lexer::new("r#\"a\"b\"#");
1490        let tokens = lexer.tokenize().unwrap();
1491        assert_eq!(tokens[0].kind, TokenKind::RawStringLiteral("a\"b".into()));
1492    }
1493
1494    #[test]
1495    fn test_hashed_raw_string_regex_with_quotes() {
1496        // The motivating case: a regex matching quoted strings, no escaping.
1497        let mut lexer = Lexer::new("r#\"\"([^\"\\]*)\"\"#");
1498        let tokens = lexer.tokenize().unwrap();
1499        assert_eq!(
1500            tokens[0].kind,
1501            TokenKind::RawStringLiteral("\"([^\"\\]*)\"".into())
1502        );
1503    }
1504
1505    #[test]
1506    fn test_double_hashed_raw_string_holds_quote_hash() {
1507        // r##"a"#b"## — the `"#` run is shorter than the 2-hash delimiter,
1508        // so it stays literal.
1509        let mut lexer = Lexer::new("r##\"a\"#b\"##");
1510        let tokens = lexer.tokenize().unwrap();
1511        assert_eq!(tokens[0].kind, TokenKind::RawStringLiteral("a\"#b".into()));
1512    }
1513
1514    #[test]
1515    fn test_plain_raw_string_still_works() {
1516        let mut lexer = Lexer::new("r\"plain\"");
1517        let tokens = lexer.tokenize().unwrap();
1518        assert_eq!(tokens[0].kind, TokenKind::RawStringLiteral("plain".into()));
1519    }
1520
1521    #[test]
1522    fn test_hashed_raw_string_unterminated_errors() {
1523        let mut lexer = Lexer::new("r#\"no close\"");
1524        assert!(lexer.tokenize().is_err());
1525    }
1526
1527    #[test]
1528    fn test_hashed_raw_string_newline_errors() {
1529        let mut lexer = Lexer::new("r#\"line1\nline2\"#");
1530        assert!(lexer.tokenize().is_err());
1531    }
1532}