Skip to main content

vtcode_commons/
lexical.rs

1//! Lightweight lexical classification of source text.
2//!
3//! The search tools need to tell an identifier occurrence apart from the same
4//! text inside a string literal or a comment. A full parser is not needed for
5//! that, and parsing every file is expensive, so this module scans the text
6//! once and records which bytes are *code*.
7//!
8//! The scanner is deliberately conservative: it understands line comments,
9//! block comments, and string literals, which is what distinguishes a real
10//! identifier occurrence from a mention in a comment or a string. When it
11//! meets syntax it does not understand, it keeps scanning as code, so an
12//! unrecognised construct can only cause a *text* result to be reported as a
13//! usage, never a hit to be lost.
14
15/// The comment and string syntax of one language.
16///
17/// Empty delimiters are ignored: a comment prefix or a block-comment pair with
18/// an empty side would otherwise match at every position (or panic while
19/// scanning for the closing delimiter).
20#[derive(Debug, Clone, Copy)]
21pub struct LanguageSyntax {
22    /// Prefixes that start a comment running to the end of the line.
23    pub line_comments: &'static [&'static str],
24    /// Delimiter pairs that start and end a block comment.
25    pub block_comments: &'static [(&'static str, &'static str)],
26    /// Delimiters that start and end a string literal.
27    ///
28    /// Longer delimiters are matched first, so a language can list `"""`
29    /// alongside `"` to support multi-line strings.
30    pub string_delimiters: &'static [&'static str],
31    /// Whether a backslash escapes the next character inside a string.
32    pub backslash_escapes: bool,
33}
34
35impl LanguageSyntax {
36    /// Returns the delimiter that starts a string at `bytes[index]`, if any.
37    ///
38    /// The longest matching delimiter wins, so a multi-line delimiter is
39    /// preferred over a single-character one.
40    fn string_delimiter_at(&self, bytes: &[u8], index: usize) -> Option<&'static str> {
41        let rest = bytes.get(index..)?;
42        self.string_delimiters
43            .iter()
44            .filter(|delimiter| !delimiter.is_empty() && rest.starts_with(delimiter.as_bytes()))
45            .max_by_key(|delimiter| delimiter.len())
46            .copied()
47    }
48}
49
50impl Default for LanguageSyntax {
51    fn default() -> Self {
52        Self {
53            line_comments: &[],
54            block_comments: &[],
55            string_delimiters: &["\""],
56            backslash_escapes: true,
57        }
58    }
59}
60
61/// Marks which bytes of `source` are code rather than string or comment text.
62///
63/// The returned vector has one entry per byte of `source`; `true` means the
64/// byte is part of code.
65#[must_use]
66pub fn code_mask(source: &str, syntax: &LanguageSyntax) -> Vec<bool> {
67    let bytes = source.as_bytes();
68    let mut mask = vec![true; bytes.len()];
69    let mut index = 0usize;
70
71    while index < bytes.len() {
72        if let Some(end) = match_line_comment(bytes, index, syntax) {
73            mark(&mut mask, index, end);
74            index = end;
75            continue;
76        }
77        if let Some(end) = match_block_comment(bytes, index, syntax) {
78            mark(&mut mask, index, end);
79            index = end;
80            continue;
81        }
82        if let Some(end) = match_string(bytes, index, syntax) {
83            mark(&mut mask, index, end);
84            index = end;
85            continue;
86        }
87        index += 1;
88    }
89
90    mask
91}
92
93/// Marks a byte range as not being code.
94fn mark(mask: &mut [bool], start: usize, end: usize) {
95    let Some(range) = mask.get_mut(start..end.min(mask.len())) else {
96        return;
97    };
98    for slot in range {
99        *slot = false;
100    }
101}
102
103/// Returns the end of a line comment starting at `index`, if there is one.
104fn match_line_comment(bytes: &[u8], index: usize, syntax: &LanguageSyntax) -> Option<usize> {
105    let rest = bytes.get(index..)?;
106    for prefix in syntax.line_comments {
107        // An empty prefix matches everywhere, so it would comment out the whole
108        // file. Treat it as "no comment syntax" instead.
109        if prefix.is_empty() {
110            continue;
111        }
112        if rest.starts_with(prefix.as_bytes()) {
113            return Some(
114                rest.iter()
115                    .position(|byte| *byte == b'\n')
116                    .map_or(bytes.len(), |offset| index + offset),
117            );
118        }
119    }
120    None
121}
122
123/// Returns the end of a block comment starting at `index`, if there is one.
124fn match_block_comment(bytes: &[u8], index: usize, syntax: &LanguageSyntax) -> Option<usize> {
125    let rest = bytes.get(index..)?;
126    for (open, close) in syntax.block_comments {
127        // Both delimiters must be non-empty: an empty `open` matches at every
128        // position, and an empty `close` has no valid window to scan for.
129        if open.is_empty() || close.is_empty() {
130            continue;
131        }
132        if !rest.starts_with(open.as_bytes()) {
133            continue;
134        }
135        let body_start = index + open.len();
136        return Some(
137            match bytes
138                .get(body_start..)
139                .unwrap_or_default()
140                .windows(close.len())
141                .position(|window| window == close.as_bytes())
142            {
143                Some(offset) => body_start + offset + close.len(),
144                None => bytes.len(),
145            },
146        );
147    }
148    None
149}
150
151/// Returns the end of a string literal starting at `index`, if there is one.
152fn match_string(bytes: &[u8], index: usize, syntax: &LanguageSyntax) -> Option<usize> {
153    let delimiter = syntax.string_delimiter_at(bytes, index)?;
154    let body_start = index + delimiter.len();
155    let mut cursor = body_start;
156
157    while let Some(byte) = bytes.get(cursor) {
158        if syntax.backslash_escapes && *byte == b'\\' {
159            cursor = (cursor + 2).min(bytes.len());
160            continue;
161        }
162        if bytes.get(cursor..).unwrap_or_default().starts_with(delimiter.as_bytes()) {
163            return Some(cursor + delimiter.len());
164        }
165        // A single-line string ends at the newline unless the delimiter spans
166        // lines, so an unterminated literal cannot swallow the rest of file.
167        if delimiter.len() == 1 && *byte == b'\n' {
168            return Some(cursor);
169        }
170        cursor += 1;
171    }
172
173    Some(bytes.len())
174}
175
176#[cfg(test)]
177mod tests {
178    use super::{LanguageSyntax, code_mask};
179
180    const RUST: LanguageSyntax = LanguageSyntax {
181        line_comments: &["//"],
182        block_comments: &[("/*", "*/")],
183        string_delimiters: &["\""],
184        backslash_escapes: true,
185    };
186
187    fn code_at(source: &str, needle: &str) -> bool {
188        let mask = code_mask(source, &RUST);
189        let start = source.find(needle).expect("needle present");
190        mask[start]
191    }
192
193    #[test]
194    fn identifier_occurrences_are_code() {
195        assert!(code_at("let Widget = 1;", "Widget"));
196    }
197
198    #[test]
199    fn string_contents_are_not_code() {
200        assert!(!code_at("let label = \"Widget\";", "Widget"));
201    }
202
203    #[test]
204    fn comment_contents_are_not_code() {
205        assert!(!code_at("// Widget comment", "Widget"));
206        assert!(!code_at("/* Widget */", "Widget"));
207    }
208
209    #[test]
210    fn escaped_quotes_do_not_end_the_string_early() {
211        let source = "let label = \"a\\\"Widget\"; let x = Widget;";
212        assert!(!code_at(source, "Widget\""));
213        assert!(code_at(source, "Widget;"));
214    }
215
216    #[test]
217    fn block_comments_span_lines() {
218        let source = "/* start\nWidget\nend */ let y = Widget;";
219        let mask = code_mask(source, &RUST);
220        let widget = source.find("Widget").expect("needle present");
221        assert!(!mask[widget], "the commented occurrence is not code");
222        let last = source.rfind("Widget").expect("second occurrence");
223        assert!(mask[last], "the code occurrence is code");
224    }
225
226    #[test]
227    fn languages_without_comments_treat_everything_as_code() {
228        let syntax = LanguageSyntax { line_comments: &[], ..LanguageSyntax::default() };
229        let source = "// Widget";
230        assert!(code_mask(source, &syntax)[source.find("Widget").expect("needle present")]);
231    }
232
233    #[test]
234    fn python_style_comments_are_recognised() {
235        let syntax = LanguageSyntax {
236            line_comments: &["#"],
237            block_comments: &[],
238            string_delimiters: &["\"", "'"],
239            backslash_escapes: true,
240        };
241        let source = "# Widget\nx = 'Widget'\ny = Widget";
242        let mask = code_mask(source, &syntax);
243        let comment = source.find("Widget").expect("needle present");
244        let string = source.find("'Widget'").expect("string present") + 1;
245        let code = source.rfind("Widget").expect("needle present");
246        assert!(!mask[comment], "a comment mention is not code");
247        assert!(!mask[string], "a string literal is not code");
248        assert!(mask[code], "a bare identifier is code");
249    }
250}