diff --git a/.isu/issues.json b/.isu/issues.json index cc790b9..17c1410 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -4413,7 +4413,7 @@ "labels": [], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-06-05T00:36:21Z" }, { diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs index 11d422b..8c9d873 100644 --- a/crates/js/src/lexer.rs +++ b/crates/js/src/lexer.rs @@ -332,6 +332,15 @@ pub struct Lexer<'a> { /// Brace contexts used to recognize a just-closed function declaration body. brace_context_stack: Vec, last_closed_brace_context: Option, + /// Stack tracking, for each open `(`, whether it begins a control-flow + /// header (`if`/`for`/`while`/`with`) whose closing `)` is followed by a + /// statement/expression-start position. Used to disambiguate `/`. + paren_header_stack: Vec, + /// Set when the just-scanned token is a control-flow keyword, so the + /// immediately following `(` is recorded as a header paren. + pending_paren_is_header: bool, + /// Whether the most recently closed `)` ended a control-flow header. + last_rparen_was_keyword_header: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -364,6 +373,9 @@ impl<'a> Lexer<'a> { pending_function: None, brace_context_stack: Vec::new(), last_closed_brace_context: None, + paren_header_stack: Vec::new(), + pending_paren_is_header: false, + last_rparen_was_keyword_header: false, } } @@ -587,6 +599,36 @@ impl<'a> Lexer<'a> { paren_depth: 0, }); } + + self.update_paren_tracking(kind); + } + + /// Track which `(`/`)` pairs are control-flow headers so the lexer can tell + /// that a `/` after a header's `)` begins a RegExp literal, not division + /// (e.g. `for (...) /re/.test(x)` or `if (...) /re/.test(x)`). + fn update_paren_tracking(&mut self, kind: &TokenKind) { + match kind { + TokenKind::LParen => { + self.paren_header_stack.push(self.pending_paren_is_header); + self.pending_paren_is_header = false; + } + TokenKind::RParen => { + self.last_rparen_was_keyword_header = + self.paren_header_stack.pop().unwrap_or(false); + } + TokenKind::If | TokenKind::For | TokenKind::While | TokenKind::With => { + // A control-flow keyword introduces a header paren only when it + // is used as a statement keyword, not as a property name such + // as `obj.for(...)` / `obj?.while(...)`. + self.pending_paren_is_header = !matches!( + self.last_token_kind, + Some(TokenKind::Dot) | Some(TokenKind::QuestionDot) + ); + } + _ => { + self.pending_paren_is_header = false; + } + } } fn function_starts_declaration(&self) -> bool { @@ -612,6 +654,12 @@ impl<'a> Lexer<'a> { { return false; } + // A `)` that closes a control-flow header (`if`/`for`/`while`/`with`) + // is followed by statement/expression-start position, so a subsequent + // `/` is a RegExp literal rather than division. + if matches!(kind, TokenKind::RParen) && self.last_rparen_was_keyword_header { + return false; + } token_is_expr_end(kind) } @@ -2113,6 +2161,81 @@ mod tests { ); } + #[test] + fn test_regexp_after_for_header() { + // `for (let t of x) /re/.test(t)` — the for-of body is an expression + // statement starting with a RegExp literal, not division. (isu #361) + let tokens = kinds("for(let t of x)/didomi/gi.test(t)"); + assert_eq!( + tokens, + vec![ + TokenKind::For, + TokenKind::LParen, + TokenKind::Let, + TokenKind::Identifier("t".into()), + TokenKind::Of, + TokenKind::Identifier("x".into()), + TokenKind::RParen, + TokenKind::RegExp { + pattern: "didomi".into(), + flags: "gi".into() + }, + TokenKind::Dot, + TokenKind::Identifier("test".into()), + TokenKind::LParen, + TokenKind::Identifier("t".into()), + TokenKind::RParen, + TokenKind::Eof, + ] + ); + } + + #[test] + fn test_regexp_after_if_header() { + // `if (a) /re/.test(x)` — the consequent is an expression statement + // beginning with a RegExp literal. + let tokens = kinds("if(a)/re/.test(x)"); + assert_eq!( + tokens[4], + TokenKind::RegExp { + pattern: "re".into(), + flags: "".into() + } + ); + } + + #[test] + fn test_regexp_after_while_header() { + let tokens = kinds("while(a)/re/g.exec(x)"); + assert_eq!( + tokens[4], + TokenKind::RegExp { + pattern: "re".into(), + flags: "g".into() + } + ); + } + + #[test] + fn test_division_after_member_named_for() { + // `obj.for(x) / y` — here `for` is a property name, so the call's `)` + // ends an expression and the following `/` is division, not a RegExp. + let tokens = kinds("obj.for(x)/y/z"); + assert_eq!(tokens[6], TokenKind::Slash); + assert_eq!(tokens[8], TokenKind::Slash); + } + + #[test] + fn test_division_after_nested_header_group() { + // The inner `(a + b)` is a grouping paren inside the `if` header; its + // `)` must not be mistaken for the header-closing `)`. + let tokens = kinds("if((a+b))x/y/z"); + // tokens: if ( ( a + b ) ) x / y / z + assert_eq!(tokens[8], TokenKind::Identifier("x".into())); + assert_eq!(tokens[9], TokenKind::Slash); + assert_eq!(tokens[11], TokenKind::Slash); + } + #[test] fn test_slash_assign() { let tokens = kinds("a /= b"); diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index aafbd4d..13e09d3 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -2909,6 +2909,15 @@ mod tests { Parser::parse("function f(){}/iPhone|iPad/.test(navigator.userAgent);").unwrap(); } + #[test] + fn test_regexp_after_control_flow_header() { + // isu #361: the for-of body is an expression statement beginning with a + // RegExp literal; `/` after the header `)` must lex as a regex. + Parser::parse("for(let t of Object.keys(e))/didomi/gi.test(t)||0;").unwrap(); + Parser::parse("if(a)/re/.test(x);").unwrap(); + Parser::parse("while(a)/re/g.exec(x);").unwrap(); + } + #[test] fn test_async_function_expression_object_function_property() { Parser::parse(