Skip to main content

seqc/parser/
token.rs

1//! Token type and low-level tokenization/escape/float helpers.
2
3/// A token with its source position (1-indexed).
4#[derive(Debug, Clone)]
5pub struct Token {
6    pub text: String,
7    /// Line number (0-indexed for LSP compatibility)
8    pub line: usize,
9    /// Column number (0-indexed)
10    pub column: usize,
11}
12
13impl Token {
14    fn new(text: String, line: usize, column: usize) -> Self {
15        Token { text, line, column }
16    }
17}
18
19impl PartialEq<&str> for Token {
20    fn eq(&self, other: &&str) -> bool {
21        self.text == *other
22    }
23}
24
25impl PartialEq<str> for Token {
26    fn eq(&self, other: &str) -> bool {
27        self.text == other
28    }
29}
30
31pub(super) fn annotate_error_with_line(msg: String, tok: Option<&Token>) -> String {
32    if msg.starts_with("at line ") {
33        return msg;
34    }
35    let line = tok.map(|t| t.line).unwrap_or(0);
36    format!("at line {}: {}", line + 1, msg)
37}
38
39/// Check if a token looks like a float literal
40///
41/// Float literals contain either:
42/// - A decimal point: `3.14`, `.5`, `5.`
43/// - Scientific notation: `1e10`, `1E-5`, `1.5e3`
44///
45/// This check must happen BEFORE integer parsing to avoid
46/// parsing "5" in "5.0" as an integer.
47pub(super) fn is_float_literal(token: &str) -> bool {
48    // Skip leading minus sign for negative numbers
49    let s = token.strip_prefix('-').unwrap_or(token);
50
51    // Must have at least one digit
52    if s.is_empty() {
53        return false;
54    }
55
56    // Check for decimal point or scientific notation
57    s.contains('.') || s.contains('e') || s.contains('E')
58}
59
60/// Process escape sequences in a string literal, returning the raw byte
61/// payload. Seq strings are byte-clean — `\xNN` produces the literal byte
62/// `0xNN`, not the UTF-8 encoding of the codepoint U+00NN.
63///
64/// Supported escape sequences:
65/// - `\"` -> `"`  (quote)
66/// - `\\` -> `\`  (backslash)
67/// - `\n` -> newline
68/// - `\r` -> carriage return
69/// - `\t` -> tab
70/// - `\xNN` -> the single byte `0xNN` (00-FF)
71///
72/// # `\xNN` byte semantics
73///
74/// `\xNN` is a *byte*, not a codepoint:
75/// - `\x41` -> `0x41` ('A')
76/// - `\x1b` -> `0x1B` (ESC, for ANSI terminal codes)
77/// - `\xDC` -> `0xDC` (one byte; not the 2-byte UTF-8 of U+00DC)
78/// - `\x00` -> `0x00` (one NUL byte; embedded NULs are legal)
79///
80/// Non-escape characters in the source are copied to the output as their
81/// UTF-8 byte sequence — so `"héllo"` is still 6 UTF-8 bytes. The change
82/// is only that `\xNN` no longer round-trips through `char` (which it
83/// did before, silently producing 2-byte UTF-8 for high-byte escapes).
84///
85/// This makes byte-clean binary protocol literals (OSC alignment NULs,
86/// raw IEEE-754 byte patterns, magic-number headers) expressible in
87/// Seq source.
88///
89/// # Errors
90/// Returns error if an unknown escape sequence is encountered.
91pub(super) fn unescape_string(s: &str) -> Result<Vec<u8>, String> {
92    let mut result: Vec<u8> = Vec::with_capacity(s.len());
93    let mut chars = s.chars();
94
95    while let Some(ch) = chars.next() {
96        if ch == '\\' {
97            match chars.next() {
98                Some('"') => result.push(b'"'),
99                Some('\\') => result.push(b'\\'),
100                Some('n') => result.push(b'\n'),
101                Some('r') => result.push(b'\r'),
102                Some('t') => result.push(b'\t'),
103                Some('x') => {
104                    // Hex escape: \xNN — emit the literal byte 0xNN.
105                    let hex1 = chars.next().ok_or_else(|| {
106                        "Incomplete hex escape sequence '\\x' - expected 2 hex digits".to_string()
107                    })?;
108                    let hex2 = chars.next().ok_or_else(|| {
109                        format!(
110                            "Incomplete hex escape sequence '\\x{}' - expected 2 hex digits",
111                            hex1
112                        )
113                    })?;
114
115                    let hex_str: String = [hex1, hex2].iter().collect();
116                    let byte_val = u8::from_str_radix(&hex_str, 16).map_err(|_| {
117                        format!(
118                            "Invalid hex escape sequence '\\x{}' - expected 2 hex digits (00-FF)",
119                            hex_str
120                        )
121                    })?;
122
123                    result.push(byte_val);
124                }
125                Some(c) => {
126                    return Err(format!(
127                        "Unknown escape sequence '\\{}' in string literal. \
128                         Supported: \\\" \\\\ \\n \\r \\t \\xNN",
129                        c
130                    ));
131                }
132                None => {
133                    return Err("String ends with incomplete escape sequence '\\'".to_string());
134                }
135            }
136        } else {
137            // Source-level char: emit its UTF-8 bytes verbatim.
138            let mut buf = [0u8; 4];
139            result.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
140        }
141    }
142
143    Ok(result)
144}
145
146/// Push the accumulated token (if any) onto `tokens` and reset the buffer.
147fn flush_token(tokens: &mut Vec<Token>, current: &mut String, start_line: usize, start_col: usize) {
148    if !current.is_empty() {
149        tokens.push(Token::new(current.clone(), start_line, start_col));
150        current.clear();
151    }
152}
153
154/// Split source into tokens, tracking line/column and keeping string literals
155/// (with their escapes) intact. Newlines are emitted as `"\n"` tokens so the
156/// parser can handle line comments.
157pub(super) fn tokenize(source: &str) -> Vec<Token> {
158    let mut tokens = Vec::new();
159    let mut current = String::new();
160    let mut current_start_line = 0;
161    let mut current_start_col = 0;
162    let mut in_string = false;
163    let mut prev_was_backslash = false;
164
165    // Track current position (0-indexed)
166    let mut line = 0;
167    let mut col = 0;
168
169    for ch in source.chars() {
170        if in_string {
171            current.push(ch);
172            if ch == '"' && !prev_was_backslash {
173                // Unescaped quote ends the string
174                in_string = false;
175                tokens.push(Token::new(
176                    current.clone(),
177                    current_start_line,
178                    current_start_col,
179                ));
180                current.clear();
181                prev_was_backslash = false;
182            } else if ch == '\\' && !prev_was_backslash {
183                // Start of escape sequence
184                prev_was_backslash = true;
185            } else {
186                // Regular character or escaped character
187                prev_was_backslash = false;
188            }
189            // Track newlines inside strings
190            if ch == '\n' {
191                line += 1;
192                col = 0;
193            } else {
194                col += 1;
195            }
196        } else if ch == '"' {
197            flush_token(
198                &mut tokens,
199                &mut current,
200                current_start_line,
201                current_start_col,
202            );
203            in_string = true;
204            current_start_line = line;
205            current_start_col = col;
206            current.push(ch);
207            prev_was_backslash = false;
208            col += 1;
209        } else if ch.is_whitespace() {
210            flush_token(
211                &mut tokens,
212                &mut current,
213                current_start_line,
214                current_start_col,
215            );
216            // Preserve newlines for comment handling
217            if ch == '\n' {
218                tokens.push(Token::new("\n".to_string(), line, col));
219                line += 1;
220                col = 0;
221            } else {
222                col += 1;
223            }
224        } else if "():;[]{},#".contains(ch) {
225            // `#` is split out so that `#comment` (no space) tokenizes as
226            // `#` + `comment` and the parser's `skip_comments` consumes
227            // it as a line comment, matching Python/Bash/Ruby behaviour.
228            // Without this split, `#comment` would accumulate into a
229            // single identifier-shaped token and reach the parser as an
230            // undefined word call.
231            flush_token(
232                &mut tokens,
233                &mut current,
234                current_start_line,
235                current_start_col,
236            );
237            tokens.push(Token::new(ch.to_string(), line, col));
238            col += 1;
239        } else {
240            if current.is_empty() {
241                current_start_line = line;
242                current_start_col = col;
243            }
244            current.push(ch);
245            col += 1;
246        }
247    }
248
249    // Check for unclosed string literal
250    if in_string {
251        // Return error by adding a special error token
252        // The parser will handle this as a parse error
253        tokens.push(Token::new(
254            "<<<UNCLOSED_STRING>>>".to_string(),
255            current_start_line,
256            current_start_col,
257        ));
258    } else if !current.is_empty() {
259        tokens.push(Token::new(current, current_start_line, current_start_col));
260    }
261
262    tokens
263}