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