Skip to main content

fallow_extract/
nesting.rs

1//! Nesting-depth guard that runs before the Oxc parser sees a source.
2//!
3//! The Oxc parser and the AST visitors recurse once per nesting level and
4//! have no depth limit (oxc-project/oxc#21953, oxc-project/oxc#24375). A
5//! file with a few thousand nested brackets overflows the stack, and because
6//! a stack overflow aborts the process, one such file kills the whole run.
7//! Extraction therefore measures the bracket nesting first and does not parse
8//! a file past [`MAX_NESTING_DEPTH`].
9//!
10//! The scan is a lexer approximation. Brackets in strings, comments and
11//! template text do not count. A string or regex literal that does not end on
12//! its line is not a literal, so the scan reads it again as code: a stray
13//! quote in JSX text or a division read as a regex cannot hide brackets. A
14//! regex body keeps its unclosed openers open, so a division that looks like
15//! a regex cannot hide brackets either. It does not need to be
16//! exact, because real code stays far below the cap. Nesting without brackets
17//! (TypeScript generics, JSX elements, long member or call chains) is not
18//! capped. [`parse_bounded`] runs such a parse on a stack that is large
19//! enough for it.
20
21use std::path::Path;
22
23use fallow_types::discover::FileId;
24use fallow_types::extract::ModuleInfo;
25
26use crate::visitor::ModuleInfoExtractor;
27
28/// Bracket nesting depth past which a file is not parsed.
29///
30/// A release build on a 16 MiB worker stack overflows at about 6000 levels for
31/// the most expensive shape (nested object literals), and at about half that
32/// on the 8 MiB main thread that parses small projects. Real code stays far
33/// below this cap.
34pub const MAX_NESTING_DEPTH: u32 = 1024;
35
36/// The module of a file whose bracket nesting exceeds [`MAX_NESTING_DEPTH`].
37/// The module is a failed parse with no facts, so the file shows up as
38/// degraded instead of aborting the run.
39pub fn too_deep_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
40    let source = crate::strip_bom(source);
41    let mut info = ModuleInfoExtractor::new().into_module_info(
42        file_id,
43        content_hash,
44        crate::suppress::ParsedSuppressions::default(),
45    );
46    info.parse_error_count = 1;
47    info.parse_panicked = true;
48    info.parse_nesting_too_deep = true;
49    info.line_offsets = fallow_types::extract::compute_line_offsets(source);
50    info
51}
52
53/// Returns `true` when the bracket nesting of `source` exceeds
54/// [`MAX_NESTING_DEPTH`].
55#[must_use]
56pub fn exceeds_nesting_limit(source: &str) -> bool {
57    max_depth(source, MAX_NESTING_DEPTH, Dialect::Script) > MAX_NESTING_DEPTH
58}
59
60/// The lexical rules of the scanned source.
61#[derive(Clone, Copy, PartialEq, Eq)]
62enum Dialect {
63    /// JavaScript, TypeScript and the component formats that embed them.
64    Script,
65    /// CSS, Sass and Less. A stylesheet has no regex or template literals, and
66    /// `//` is not a comment in `url(//host/a.png)`, so only strings and
67    /// `/* */` comments are skipped. A Sass `//` comment is scanned as code,
68    /// which can only over-count.
69    Style,
70}
71
72/// Run `parse`, which parses `source` with Oxc and walks the result, without
73/// a stack overflow. Returns `None` when the bracket nesting of `source`
74/// exceeds [`MAX_NESTING_DEPTH`] or the stack it needs exceeds the cap in
75/// `stack.rs`, so the caller treats the file as a failed parse.
76///
77/// Every parse of user source goes through this function. It sizes the stack
78/// from the source length and the bracket depth, and it moves the work to a
79/// thread with that stack when the current thread has too little left. A
80/// parse of a fragment inside `parse` runs on the same stack and needs no
81/// second call.
82pub fn parse_bounded<R: Send>(source: &str, parse: impl FnOnce() -> R + Send) -> Option<R> {
83    parse_bounded_as(source, Dialect::Script, parse)
84}
85
86/// [`parse_bounded`] for a lightningcss parse of stylesheet source. The
87/// nesting scan uses stylesheet rules.
88pub fn parse_style_bounded<R: Send>(source: &str, parse: impl FnOnce() -> R + Send) -> Option<R> {
89    parse_bounded_as(source, Dialect::Style, parse)
90}
91
92/// [`parse_bounded`] for the file at `path`. A CSS, Sass or Less file is
93/// scanned with stylesheet rules.
94pub fn parse_file_bounded<R: Send>(
95    path: &Path,
96    source: &str,
97    parse: impl FnOnce() -> R + Send,
98) -> Option<R> {
99    let dialect = if crate::css::is_css_file(path) {
100        Dialect::Style
101    } else {
102        Dialect::Script
103    };
104    parse_bounded_as(source, dialect, parse)
105}
106
107fn parse_bounded_as<R: Send>(
108    source: &str,
109    dialect: Dialect,
110    parse: impl FnOnce() -> R + Send,
111) -> Option<R> {
112    let depth = max_depth(source, MAX_NESTING_DEPTH, dialect);
113    if depth > MAX_NESTING_DEPTH {
114        return None;
115    }
116    let need = crate::stack::stack_need(source.len(), depth as usize)?;
117    Some(crate::stack::run_with_stack(need, parse))
118}
119
120/// Parse `source` with Oxc and call `walk` with the result, through
121/// [`parse_bounded`]. Returns `T::default()` when the bracket nesting exceeds
122/// [`MAX_NESTING_DEPTH`].
123pub fn parse_bounded_or_default<T: Send + Default>(
124    source: &str,
125    source_type: oxc_span::SourceType,
126    walk: impl FnOnce(&oxc_parser::ParserReturn<'_>) -> T + Send,
127) -> T {
128    parse_bounded(source, || {
129        let allocator = oxc_allocator::Allocator::default();
130        let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
131        walk(&parsed)
132    })
133    .unwrap_or_default()
134}
135
136/// Maximum bracket nesting depth of `source`. The scan stops as soon as the
137/// depth passes `limit`, so the result is at most `limit + 1`.
138fn max_depth(source: &str, limit: u32, dialect: Dialect) -> u32 {
139    let mut scanner = Scanner {
140        bytes: source.as_bytes(),
141        pos: 0,
142        depth: 0,
143        max_depth: 0,
144        limit,
145        dialect,
146        templates: Vec::new(),
147        regex_allowed: true,
148    };
149    scanner.scan();
150    scanner.max_depth
151}
152
153struct Scanner<'a> {
154    bytes: &'a [u8],
155    pos: usize,
156    depth: u32,
157    max_depth: u32,
158    limit: u32,
159    dialect: Dialect,
160    /// Depth of each open `${` substitution, innermost last. The `}` that
161    /// brings the depth back to this value resumes the template text.
162    templates: Vec<u32>,
163    /// Whether a `/` at this point starts a regex literal instead of a
164    /// division, judged from the previous significant byte.
165    regex_allowed: bool,
166}
167
168impl Scanner<'_> {
169    fn scan(&mut self) {
170        while let Some(&byte) = self.bytes.get(self.pos) {
171            if self.max_depth > self.limit {
172                return;
173            }
174            self.pos += 1;
175            match byte {
176                b'\\' if self.dialect == Dialect::Style => self.skip_escape(),
177                b'(' if self.dialect == Dialect::Style && self.at_unquoted_url() => {
178                    self.skip_unquoted_url();
179                }
180                b'(' | b'[' | b'{' => {
181                    self.open();
182                    self.regex_allowed = true;
183                }
184                b'}' if self.templates.last() == Some(&self.depth) => {
185                    self.templates.pop();
186                    self.depth -= 1;
187                    self.skip_template_text();
188                }
189                b')' | b']' | b'}' => {
190                    self.depth = self.depth.saturating_sub(1);
191                    self.regex_allowed = byte == b'}';
192                }
193                b'\'' | b'"' => {
194                    self.skip_string(byte);
195                    self.regex_allowed = false;
196                }
197                b'`' if self.dialect == Dialect::Script => self.skip_template_text(),
198                b'/' if self.dialect == Dialect::Style => self.skip_block_comment(),
199                b'/' => self.skip_slash(),
200                b' ' | b'\t' | b'\n' | b'\r' => {}
201                _ => self.regex_allowed = starts_expression(byte),
202            }
203        }
204    }
205
206    fn open(&mut self) {
207        self.depth += 1;
208        self.max_depth = self.max_depth.max(self.depth);
209    }
210
211    /// Skips a comment, a regex literal, or a division operator.
212    fn skip_slash(&mut self) {
213        match self.bytes.get(self.pos) {
214            Some(b'/') => {
215                while self.pos < self.bytes.len() && !self.at_line_terminator() {
216                    self.pos += 1;
217                }
218            }
219            Some(b'*') => self.skip_block_comment(),
220            _ if self.may_start_regex() && self.skip_regex() => {
221                self.regex_allowed = false;
222            }
223            _ => self.regex_allowed = true,
224        }
225    }
226
227    /// Whether the `(` at `pos - 1` opens an unquoted CSS `url(`, whose body is
228    /// raw text: quotes, `/*` and brackets in it are not tokens.
229    fn at_unquoted_url(&self) -> bool {
230        let open = self.pos - 1;
231        let Some(name) = open.checked_sub(3).map(|start| &self.bytes[start..open]) else {
232            return false;
233        };
234        let starts_word = open
235            .checked_sub(4)
236            .is_none_or(|before| !is_css_name_byte(self.bytes[before]));
237        let body = self.bytes[self.pos..].trim_ascii_start();
238        name.eq_ignore_ascii_case(b"url")
239            && starts_word
240            && !matches!(body.first(), Some(b'\'' | b'"'))
241    }
242
243    /// Skips an unquoted `url(` body up to its `)`. Quotes and `/*` in it are
244    /// raw text. Its brackets still count, as in a regex body: a `(` that only
245    /// looks like `url(` must not hide nesting. A CSS escape skips the next
246    /// character.
247    fn skip_unquoted_url(&mut self) {
248        self.open();
249        let mut local = 0_u32;
250        let mut peak = 0_u32;
251        while let Some(&byte) = self.bytes.get(self.pos) {
252            self.pos += 1;
253            match byte {
254                b'\\' => self.skip_escape(),
255                b'(' | b'[' | b'{' => {
256                    local += 1;
257                    peak = peak.max(local);
258                }
259                b')' if local == 0 => break,
260                b')' | b']' | b'}' => local = local.saturating_sub(1),
261                _ => {}
262            }
263        }
264        self.max_depth = self.max_depth.max(self.depth + peak);
265        self.depth = self.depth - 1 + local;
266    }
267
268    /// Skips a `/* */` comment when one starts at the `/` at `pos - 1`.
269    fn skip_block_comment(&mut self) {
270        if self.bytes.get(self.pos) != Some(&b'*') {
271            return;
272        }
273        self.pos += 1;
274        while self.pos < self.bytes.len() && !self.bytes[self.pos..].starts_with(b"*/") {
275            self.pos += 1;
276        }
277        self.pos = (self.pos + 2).min(self.bytes.len());
278    }
279
280    /// Skips a string body. A quote without a closing quote on its line is
281    /// not a string, so the scan goes on after the quote as code.
282    fn skip_string(&mut self, quote: u8) {
283        let start = self.pos;
284        while let Some(&byte) = self.bytes.get(self.pos) {
285            self.pos += 1;
286            match byte {
287                b'\\' => self.skip_escape(),
288                // A string may hold U+2028 and U+2029 since ES2019. A form
289                // feed is a newline in CSS.
290                b'\n' | b'\r' => break,
291                0x0C if self.dialect == Dialect::Style => break,
292                _ if byte == quote => return,
293                _ => {}
294            }
295        }
296        self.pos = start;
297    }
298
299    /// Skips a regex body. A real regex balances its own groups, so its
300    /// brackets leave the running depth as it was. A division that only looks
301    /// like a regex must not hide brackets, so the body raises the peak depth
302    /// and its unclosed openers stay open. A body without a closing `/` on its
303    /// line is a division.
304    fn skip_regex(&mut self) -> bool {
305        let start = self.pos;
306        // Depth before the open character class. A class closes at its first
307        // `]` and its brackets are literal, so the close restores this depth.
308        let mut class_base = None;
309        let mut local = 0_u32;
310        let mut peak = 0_u32;
311        while let Some(&byte) = self.bytes.get(self.pos) {
312            if self.at_line_terminator() {
313                break;
314            }
315            self.pos += 1;
316            match byte {
317                // A regex cannot continue on the next line.
318                b'\\' if self.at_line_terminator() => break,
319                b'\\' => self.pos += 1,
320                b']' if class_base.is_some() => local = class_base.take().unwrap_or(0),
321                b'(' | b'[' | b'{' => {
322                    if byte == b'[' && class_base.is_none() {
323                        class_base = Some(local);
324                    }
325                    local += 1;
326                    peak = peak.max(local);
327                }
328                b')' | b']' | b'}' => local = local.saturating_sub(1),
329                b'/' if class_base.is_none() => {
330                    self.max_depth = self.max_depth.max(self.depth + peak);
331                    self.depth += local;
332                    return true;
333                }
334                _ => {}
335            }
336        }
337        self.pos = start;
338        false
339    }
340
341    /// Skips template text up to the closing backtick or the next `${`,
342    /// which opens one nesting level.
343    fn skip_template_text(&mut self) {
344        while let Some(&byte) = self.bytes.get(self.pos) {
345            self.pos += 1;
346            match byte {
347                b'\\' => self.skip_escape(),
348                b'`' => {
349                    self.regex_allowed = false;
350                    return;
351                }
352                b'$' if self.bytes.get(self.pos) == Some(&b'{') => {
353                    self.pos += 1;
354                    self.open();
355                    self.templates.push(self.depth);
356                    self.regex_allowed = true;
357                    return;
358                }
359                _ => {}
360            }
361        }
362    }
363}
364
365/// Whether `byte` can be part of a CSS identifier, so `myurl(` is not `url(`.
366const fn is_css_name_byte(byte: u8) -> bool {
367    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') || byte >= 0x80
368}
369
370/// Keywords after which a `/` starts a regex literal.
371const REGEX_KEYWORDS: &[&[u8]] = &[
372    b"await",
373    b"case",
374    b"delete",
375    b"do",
376    b"else",
377    b"in",
378    b"instanceof",
379    b"new",
380    b"of",
381    b"return",
382    b"throw",
383    b"typeof",
384    b"void",
385    b"yield",
386];
387
388impl Scanner<'_> {
389    /// Whether the `/` at `pos - 1` can start a regex literal. In `</` and
390    /// `/>` it is part of a JSX tag.
391    fn may_start_regex(&self) -> bool {
392        let jsx_tag = self.pos >= 2 && self.bytes[self.pos - 2] == b'<'
393            || self.bytes.get(self.pos) == Some(&b'>');
394        !jsx_tag && (self.regex_allowed || self.after_keyword())
395    }
396
397    /// Whether an ECMAScript line terminator starts at `pos`: LF, CR, U+2028
398    /// or U+2029. Oxc ends a comment or a regex at each of them.
399    fn at_line_terminator(&self) -> bool {
400        self.line_terminator_len() > 0
401    }
402
403    /// Byte length of the line terminator at `pos`, or 0. CR LF is one
404    /// terminator.
405    fn line_terminator_len(&self) -> usize {
406        match self.bytes.get(self.pos..) {
407            Some([b'\r', b'\n', ..]) => 2,
408            Some([b'\n' | b'\r', ..]) => 1,
409            Some([0xE2, 0x80, 0xA8 | 0xA9, ..]) => 3,
410            _ => 0,
411        }
412    }
413
414    /// Steps over the character after a backslash at `pos - 1`. A backslash
415    /// before a line terminator continues a string or template on the next
416    /// line, so the whole terminator is skipped.
417    fn skip_escape(&mut self) {
418        self.pos += self.line_terminator_len().max(1);
419    }
420
421    /// Whether the word before the `/` at `pos - 1` is a keyword that a regex
422    /// literal can follow.
423    fn after_keyword(&self) -> bool {
424        let before = &self.bytes[..self.pos - 1];
425        let before = before.trim_ascii_end();
426        let start = before
427            .iter()
428            .rposition(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')))
429            .map_or(0, |index| index + 1);
430        REGEX_KEYWORDS.contains(&&before[start..])
431    }
432}
433
434/// Whether a `/` after `byte` starts a regex literal. After an identifier,
435/// a number or a closing bracket it is a division.
436const fn starts_expression(byte: u8) -> bool {
437    !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$' | b'.' | b')' | b']'))
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    fn depth_of(source: &str) -> u32 {
445        max_depth(source, u32::MAX, Dialect::Script)
446    }
447
448    fn style_depth_of(source: &str) -> u32 {
449        max_depth(source, u32::MAX, Dialect::Style)
450    }
451
452    #[test]
453    fn style_sources_skip_strings_and_block_comments_only() {
454        assert_eq!(style_depth_of(".a{content:\"(((\"}/* [[[ */"), 1);
455        assert_eq!(
456            style_depth_of(".b{background:url(//cdn.example.com/a.png)}.c{a:b(c(d))}"),
457            3
458        );
459        assert_eq!(style_depth_of(".d{width:calc(100%/3)}"), 2);
460        assert_eq!(
461            style_depth_of(".e::before{content:'it\\'s'}.f{g:h((i))}"),
462            3
463        );
464        // A CSS escape is not a quote, and an unquoted url() body is raw text.
465        assert_eq!(style_depth_of(".g\\\"{a:b((c))}"), 3);
466        assert_eq!(style_depth_of(".h{a:url(/*x.png)}.i{a:b((c))}"), 3);
467        assert_eq!(style_depth_of(".j{a:url(it's.png)}.k{a:b((c))}"), 3);
468        assert_eq!(style_depth_of(".l{a:URL( \"(((\" )}"), 2);
469        assert_eq!(style_depth_of(".m{a:myurl(((b)))}"), 4);
470        // A url() body still counts its brackets, so a misread url( hides
471        // nothing.
472        assert_eq!(style_depth_of("#url({{{{"), 5);
473        assert_eq!(style_depth_of(".n{a:\"x\u{c}((((1"), 5);
474    }
475
476    #[test]
477    fn counts_code_brackets_only() {
478        assert_eq!(depth_of("f(a[b{c}])"), 3);
479        assert_eq!(
480            depth_of("'((('; \"[[[\"; `{{{`; /\\(\\(/; // (((\n/* ((( */"),
481            0
482        );
483        assert_eq!(depth_of("`a${b(`c${d}`)}`"), 3);
484        assert_eq!(depth_of("a / (b) / c"), 1);
485        assert_eq!(depth_of("x = /[/(]/ + (1)"), 2);
486        assert_eq!(depth_of("return /[(]/.test(a)"), 2);
487    }
488
489    #[test]
490    fn misread_literals_do_not_hide_brackets() {
491        assert_eq!(depth_of("x = i++ / (((1"), 3);
492        assert_eq!(depth_of("x = i++ / (((1 / 2)))"), 3);
493        assert_eq!(depth_of("<p>it's {{{a}}}</p>"), 3);
494        let lines = "x = i++ / ((1 / 2\n".repeat(500);
495        assert_eq!(depth_of(&lines), 1000);
496        for terminator in ["\r", "\u{2028}", "\u{2029}"] {
497            assert_eq!(depth_of(&format!("// a{terminator}(((1")), 3);
498            assert_eq!(depth_of(&format!("x = i++ / a{terminator}(((1")), 3);
499        }
500        assert_eq!(depth_of("'a\rb' + (((1"), 3);
501        assert_eq!(depth_of("((('a\\\r\n)))' + (((1"), 6);
502        assert_eq!(depth_of("((('a\\\u{2028})))' + (((1"), 6);
503        assert_eq!(depth_of("x = i++ /a\\\n(((1 /"), 3);
504    }
505
506    #[test]
507    fn regex_character_classes_do_not_accumulate() {
508        let regex = format!("x = /{}/;\n", "[a-z(][^)]".repeat(2000));
509        assert_eq!(depth_of(&regex), 2);
510    }
511
512    #[test]
513    fn compact_jsx_closers_keep_the_depth_balanced() {
514        let line = "<ul>{xs.map(i => (<li>{i}</li>))}</ul>\n{xs.map(i => <Item {...i} />)}\n";
515        assert_eq!(depth_of(line), 4);
516        assert_eq!(depth_of(&line.repeat(2000)), 4);
517    }
518
519    #[test]
520    fn depth_at_the_cap_is_allowed() {
521        let at_limit = "[".repeat(MAX_NESTING_DEPTH as usize);
522        assert!(!exceeds_nesting_limit(&at_limit));
523        assert!(exceeds_nesting_limit(&format!("{at_limit}[")));
524    }
525}