Skip to main content

perl_lexer/
symbol_table.rs

1//! File-local symbol table for bareword/regex disambiguation.
2//!
3//! Enables the lexer to correctly identify known subroutine names so that
4//! `identifier /regex/` is lexed as a function call with a regex argument
5//! rather than `identifier / expr` (division).
6//!
7//! # How it works
8//!
9//! Before lexing begins, the parser performs a lightweight pre-pass over the
10//! source text to collect all `sub NAME` declarations into a `LocalSymbolTable`.
11//! That table is then attached to the [`LexerConfig`][crate::LexerConfig] so
12//! that the lexer can consult it when resolving bareword/slash ambiguity.
13//!
14//! # Limitations (v1)
15//!
16//! - ASCII identifiers only; Unicode sub names are not recognized.
17//! - No imported symbols (workspace symbol table is a follow-up).
18//! - Dynamic subs (`eval "sub foo { }"`, `AUTOLOAD`) are not tracked.
19
20use std::collections::HashSet;
21
22/// File-local subroutine symbol table built from a pre-pass scan.
23///
24/// # Forward references
25///
26/// Because this is a whole-file pre-pass, subroutines declared *after* their
27/// first call site are recognized correctly.  A forward reference like:
28///
29/// ```text
30/// builder /pattern/;
31/// sub builder { ... }
32/// ```
33///
34/// will be parsed as a function call with a regex argument.
35#[derive(Debug, Clone, Default)]
36pub struct LocalSymbolTable {
37    known_subs: HashSet<Box<str>>,
38}
39
40impl LocalSymbolTable {
41    /// Scan Perl source for `sub NAME` declarations and build a symbol table.
42    ///
43    /// This is a best-effort O(n) pre-pass. It skips line comments (`#…`) and
44    /// simple single- and double-quoted string literals so that `# sub foo` or
45    /// `"sub foo"` patterns do not produce false positives.  POD sections and
46    /// heredocs are *not* specially handled; declarations inside them are a
47    /// known (harmless) false-positive class.
48    pub fn scan_subs(input: &str) -> Self {
49        let mut known_subs = HashSet::new();
50        let bytes = input.as_bytes();
51        let len = bytes.len();
52        let mut i = 0;
53
54        while i < len {
55            match bytes[i] {
56                // Line comments: skip to end of line.
57                b'#' => {
58                    while i < len && bytes[i] != b'\n' {
59                        i += 1;
60                    }
61                }
62
63                // Single-quoted strings: skip contents verbatim (backslash only
64                // escapes `\'` and `\\` inside `'...'`).
65                b'\'' => {
66                    i += 1;
67                    while i < len {
68                        if bytes[i] == b'\\' && i + 1 < len {
69                            i += 2; // skip backslash + next char
70                        } else if bytes[i] == b'\'' {
71                            i += 1;
72                            break;
73                        } else {
74                            i += 1;
75                        }
76                    }
77                }
78
79                // Double-quoted strings: skip contents (backslash escapes).
80                b'"' => {
81                    i += 1;
82                    while i < len {
83                        if bytes[i] == b'\\' && i + 1 < len {
84                            i += 2;
85                        } else if bytes[i] == b'"' {
86                            i += 1;
87                            break;
88                        } else {
89                            i += 1;
90                        }
91                    }
92                }
93
94                // Potential `sub` keyword.
95                b's' if i + 3 <= len && bytes[i..i + 3] == *b"sub" => {
96                    // Word-boundary guard: the char before must not be an ident char.
97                    let prev_ident = i > 0 && is_ident_byte(bytes[i - 1]);
98                    // The char immediately after "sub" must not be an ident char.
99                    let next_ident = i + 3 < len && is_ident_byte(bytes[i + 3]);
100
101                    if !prev_ident && !next_ident {
102                        let mut j = i + 3;
103                        // Skip horizontal whitespace and newlines.
104                        while j < len && matches!(bytes[j], b' ' | b'\t' | b'\r' | b'\n') {
105                            j += 1;
106                        }
107                        // Collect ASCII identifier (must start with letter or `_`).
108                        if j < len && (bytes[j].is_ascii_alphabetic() || bytes[j] == b'_') {
109                            let start = j;
110                            while j < len && is_ident_byte(bytes[j]) {
111                                j += 1;
112                            }
113                            // All bytes in start..j are ASCII, so this is valid UTF-8.
114                            known_subs.insert(input[start..j].into());
115                        }
116                        i = j;
117                    } else {
118                        i += 1;
119                    }
120                }
121
122                _ => {
123                    i += 1;
124                }
125            }
126        }
127
128        Self { known_subs }
129    }
130
131    /// Return `true` if `name` was declared as a subroutine in this file.
132    pub fn is_known_sub(&self, name: &str) -> bool {
133        self.known_subs.contains(name)
134    }
135
136    /// Return the number of subroutine names recorded.
137    pub fn len(&self) -> usize {
138        self.known_subs.len()
139    }
140
141    /// Return `true` if no subroutines have been recorded.
142    pub fn is_empty(&self) -> bool {
143        self.known_subs.is_empty()
144    }
145}
146
147/// Return `true` if byte `b` can appear inside a Perl identifier (`[A-Za-z0-9_]`).
148fn is_ident_byte(b: u8) -> bool {
149    b.is_ascii_alphanumeric() || b == b'_'
150}
151
152#[cfg(test)]
153mod tests {
154    use super::LocalSymbolTable;
155
156    // --- scan_subs: basic recognition ---
157
158    #[test]
159    fn scan_subs_empty_source_gives_empty_table() {
160        assert!(LocalSymbolTable::scan_subs("").is_empty());
161    }
162
163    #[test]
164    fn scan_subs_single_declaration_is_recognized() {
165        let st = LocalSymbolTable::scan_subs("sub foo { }");
166        assert!(st.is_known_sub("foo"));
167        assert_eq!(st.len(), 1);
168    }
169
170    #[test]
171    fn scan_subs_multiple_declarations_are_all_recognized() {
172        let src = "sub alpha { }\nsub beta { }\nsub gamma { }";
173        let st = LocalSymbolTable::scan_subs(src);
174        assert!(st.is_known_sub("alpha"));
175        assert!(st.is_known_sub("beta"));
176        assert!(st.is_known_sub("gamma"));
177        assert_eq!(st.len(), 3);
178    }
179
180    #[test]
181    fn scan_subs_forward_declaration_with_semicolon() {
182        let st = LocalSymbolTable::scan_subs("sub builder;");
183        assert!(st.is_known_sub("builder"));
184    }
185
186    #[test]
187    fn scan_subs_prototype_declaration() {
188        let st = LocalSymbolTable::scan_subs("sub transform ($$) { }");
189        assert!(st.is_known_sub("transform"));
190    }
191
192    #[test]
193    fn scan_subs_underscore_prefix() {
194        let st = LocalSymbolTable::scan_subs("sub _private { }");
195        assert!(st.is_known_sub("_private"));
196    }
197
198    #[test]
199    fn scan_subs_alphanumeric_name_with_digits() {
200        let st = LocalSymbolTable::scan_subs("sub process2 { }");
201        assert!(st.is_known_sub("process2"));
202    }
203
204    // --- scan_subs: comment filtering ---
205
206    #[test]
207    fn scan_subs_skips_line_comment() {
208        let src = "# sub commented_out { }\nsub real_sub { }";
209        let st = LocalSymbolTable::scan_subs(src);
210        assert!(!st.is_known_sub("commented_out"), "should not register commented-out subs");
211        assert!(st.is_known_sub("real_sub"));
212    }
213
214    #[test]
215    fn scan_subs_inline_comment_after_declaration() {
216        let src = "sub real { } # sub fake { }";
217        let st = LocalSymbolTable::scan_subs(src);
218        assert!(st.is_known_sub("real"));
219        assert!(!st.is_known_sub("fake"));
220    }
221
222    // --- scan_subs: string filtering ---
223
224    #[test]
225    fn scan_subs_skips_single_quoted_string() {
226        let src = r#"my $x = 'sub in_string { }'; sub real { }"#;
227        let st = LocalSymbolTable::scan_subs(src);
228        assert!(!st.is_known_sub("in_string"), "should not register subs in string literals");
229        assert!(st.is_known_sub("real"));
230    }
231
232    #[test]
233    fn scan_subs_skips_double_quoted_string() {
234        let src = r#"my $x = "sub in_string { }"; sub real { }"#;
235        let st = LocalSymbolTable::scan_subs(src);
236        assert!(!st.is_known_sub("in_string"));
237        assert!(st.is_known_sub("real"));
238    }
239
240    // --- scan_subs: word-boundary checks ---
241
242    #[test]
243    fn scan_subs_does_not_match_substring() {
244        // "substring" contains "sub" but is NOT a sub declaration
245        let src = "my $x = substring(1, 2);";
246        let st = LocalSymbolTable::scan_subs(src);
247        assert!(!st.is_known_sub("string"), "should not match 'sub' inside 'substring'");
248        assert!(st.is_empty());
249    }
250
251    #[test]
252    fn scan_subs_does_not_match_sub_suffix() {
253        let src = "my $x = foosubfoo;";
254        let st = LocalSymbolTable::scan_subs(src);
255        assert!(st.is_empty());
256    }
257
258    // --- is_known_sub: negative cases ---
259
260    #[test]
261    fn is_known_sub_unknown_name_returns_false() {
262        let st = LocalSymbolTable::scan_subs("sub foo { }");
263        assert!(!st.is_known_sub("bar"));
264        assert!(!st.is_known_sub(""));
265    }
266
267    // --- default ---
268
269    #[test]
270    fn default_produces_empty_table() {
271        let st = LocalSymbolTable::default();
272        assert!(st.is_empty());
273        assert!(!st.is_known_sub("anything"));
274    }
275}