Skip to content

fix(repl): skip regex literals when validating input - #36375

Merged
bartlomieju merged 3 commits into
denoland:mainfrom
xevrion:fix/repl-validate-regex-literals
Aug 12, 2026
Merged

bartlomieju merged 3 commits into
denoland:mainfrom
xevrion:fix/repl-validate-regex-literals

Conversation

@xevrion

@xevrion xevrion commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

deno_ast::lex runs the lexer without the parser, so it never recognizes regex literals. A literal comes back as a division operator followed by however the rest of the pattern happens to tokenize, and a character class containing a quote produces a stray string literal that swallows the closing ]. That leaves the [ orphaned on the bracket stack, so typing let re = /[']/; in the REPL either reported "Mismatched pairs" or blocked waiting for more input.

The repo already had the piece needed to fix this. find_regex_literal_end scans a regex literal correctly, tracking character classes and escapes, but it was only wired into syntax highlighting. validate() instead counted / tokens per line and bailed once it saw two, with a comment conceding that detecting regex literals properly was too complicated.

This reuses that helper in validate() to skip the tokens a regex literal covers.

Problem

Deciding whether a / starts a regex or is a division depends on expression position, which this code does not track. highlight_line has a regex_allowed flag for it, and its own comment notes the flag is knowingly imperfect after ) and ++/--, which is fine for coloring.

That tradeoff is not fine for validation, where a wrong guess decides whether the REPL accepts input or hangs. So rather than porting that flag, this change carries a stricter rule: once a regex literal has been skipped, the validator may only suppress an error, never report one. If skipping leaves the input looking unbalanced, it returns Valid(None) and lets V8 decide.

That makes the change strictly monotonic. A wrong "valid" is recovered immediately because V8 reports the real syntax error, whereas a wrong "invalid" or "incomplete" wedges the REPL, which is the bug being fixed. The existing division counter is kept as the fallback for a / that is not a regex.

Fixes #24963.

Test Plan

Four tests added to the existing test module in cli/tools/repl/editor.rs:

  • validate_regex_literal_with_quotes covers the reported case, /["]/, the issue's original function fn() { if (/[']/); }, both quote kinds with flags, a pattern with escaped slashes, and a regex on the second line of multi-line input.
  • validate_division_is_not_a_regex checks a / b and a / b / c still parse.
  • validate_unterminated_regex_is_incomplete checks const re = /[ still asks for more input.
  • validate_ambiguous_slash_never_reports_an_error covers the two cases the position heuristic cannot resolve, if (x) /re/.test(y) and x++ /2/ y, asserting neither produces an error.

Verified the new tests fail before the change. cargo test -p deno --lib validate_ passes 19 tests, including every pre-existing validate_* test.

One note on formatting: tools/format.js and tools/lint.js need a deno binary I could not build locally, so I ran the underlying formatter directly with the flags from .dprint.json, rustfmt --config imports_granularity=item --config group_imports=StdExternalCrate --check cli/tools/repl/editor.rs, which exits clean. Plain cargo fmt is not equivalent, since it omits both config flags.

AI disclosure

The fix and tests here were written with Claude Code, working from my direction on the approach, in particular the decision that skipping a regex may only suppress a validation error and never report one.

`deno_ast::lex` runs without the parser, so a regex literal comes back as a
division operator followed by however the rest of the pattern tokenizes. A
character class containing a quote produces a stray string literal that
swallows the closing `]`, leaving the `[` orphaned on the bracket stack, so
`/[']/` either reported mismatched pairs or blocked waiting for more input.

Reuse `find_regex_literal_end`, which already handles character classes and
escapes for syntax highlighting, to skip the tokens a regex literal covers.

Deciding that a `/` starts a regex is a guess, since expression position isn't
tracked, so once the guess has been acted on the validator only suppresses
errors and never reports one: a wrong "valid" is recovered by V8 reporting the
real syntax error, but a wrong "invalid" wedges the REPL.

Fixes denoland#24963.
@deno-cla-assistant

deno-cla-assistant Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Deno Individual Contributor License Agreement

All contributors have signed the CLA. Thank you!

Re-run CLA check


This is an automated message from CLA Assistant

@bartlomieju

Copy link
Copy Markdown
Member

Nice diagnosis, and reusing find_regex_literal_end rather than porting the
regex_allowed flag is the right call — the reasoning in the PR description
about why the highlighter's heuristic isn't good enough for validation is
exactly right.

One blocking issue though: the monotonicity guard is incomplete, so the
invariant the PR is built on doesn't quite hold.

The final guard checks queued_validation_error and stack, but the
Incomplete branch it's meant to neutralize also fires on in_template and
ends_with_dot. So skipping a regex can still introduce a new Incomplete
and wedge the REPL. Measured against the same build, base vs. this branch:

input main this PR
a / `x / y` Valid Incomplete
a / b / c. Valid Incomplete

The first one is the sharp case: the slash-scan finds its "closing /" inside
the template string, so the opening backtick ends up in the skipped span but
the closing one doesn't. in_template toggles exactly once and the REPL blocks
waiting for input.

Suggested fix — compute the result first, then suppress any non-Valid
outcome:

let result = if let Some(error) = queued_validation_error {
  error
} else if !stack.is_empty() || in_template || ends_with_dot {
  ValidationResult::Incomplete
} else {
  ValidationResult::Valid(None)
};
if skipped_regex && !matches!(result, ValidationResult::Valid(_)) {
  // We guessed that a `/` started a regex and skipped over it. If that left
  // the input looking unbalanced the guess was probably wrong, so hand it to
  // V8 rather than rejecting it or blocking on more input.
  return ValidationResult::Valid(None);
}
result

I tried this locally: both cases above go back to Valid, const re = /[ still
returns Incomplete, and all existing tools::repl::editor tests pass. Both
inputs would be good additions to validate_ambiguous_slash_never_reports_an_error
— as written that test only covers stack-balanced inputs, which is why it passes
despite the hole.

Smaller things, none blocking:

  • The suppression is whole-input rather than per-line, so one skipped regex on
    line 1 disables a genuine Mismatched pairs message 20 lines later. Still
    monotonic and consistent with the stated design, but worth a note in the
    comment.
  • validate_division_is_not_a_regex passes for the wrong reason: a / b / c is
    actually treated as a regex (/ b / plus a flag-scan that stops at the
    space) and reaches Valid through the suppression path, not the division
    counter. As written it can't catch a regression in the fallback.
  • Relatedly, the division counter is now nearly dead — it only reaches 2 when a
    line has two slashes and find_regex_literal_end rejects the first, which
    in practice means the scan hit a newline. Fine as a fallback, but the retained
    comment reads as if it still carries the old weight.
  • Since the user-visible symptom is a hung REPL, a tests/specs/repl/ case
    typing let re = /[']/; would lock the fix in end to end. Judgment call —
    that directory only has console_log and json today.

No security or performance concerns; the scan is O(line) per slash on
REPL-sized input. Happy to approve once the guard covers in_template and
ends_with_dot.

@xevrion

xevrion commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

thanks, that was a good catch, I have reproduced both cases, and applied the restructure with all the four conditions. I have taken note of the three minor points too.
added a pty test in repl_tests.rs instead of tests/specs/repl/, since that dir runs scripts rather than driving an interactive repl; happy to move it if you'd prefer.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice — reusing find_regex_literal_end (already line-scoped: it returns None on \n/\r) instead of the "two slashes and bail" heuristic is the right call, and the "a skipped regex may only ever suppress, never report" invariant is what makes this safe without tracking expression position.

Checked the cases I was worried about:

  • Multi-line input with a division on each line — the newline bail in find_regex_literal_end means nothing is skipped, so Incomplete still works and the REPL keeps reading.
  • A line that genuinely contains two /s in division position now trips the rescan and suppresses. That's not a regression: the old div_token_count >= 2 path already returned Valid(None) for exactly those inputs.
  • ends_with_dot is assigned before the continue on a detected regex, so a trailing . inside a skipped span can't leak through as a pending member access.
  • A string token that starts inside the regex span but ends past it gets dropped wholesale, which can leave the rest of the line mis-tokenized — but that lands in Valid(None) via the suppression rule, so V8 reports the real error. Fine.

Tests cover the reported case, division, the unterminated-regex Incomplete path, and the two ambiguous-position cases. CI is green. LGTM.

@bartlomieju
bartlomieju merged commit 17269e8 into denoland:main Aug 12, 2026
136 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

REPL is broken by RegExp literal with character class containing quotes

2 participants