Skip to content

Commit 09fc1f1

Browse files
authored
feat: add external tokenizer for regex vs division disambiguation (#7) (#16)
Implement the first external tokenizer in src/tokens.ts. The regex/division ambiguity is resolved by checking parser state: - At expression-start positions: /pattern/flags produces Regex token - After a value (identifier, number, ), ]): / produces divideOp (ArithOp) - Uses stack.canShift() to let the parser state decide Grammar changes: - Add Regex back to expression alternatives - Remove / from ArithOp (handled entirely by external tokenizer) - Add divideOp[@name=ArithOp] as external token alias Real-world impact: - Devise lib: 396 → 343 errors (85.5% clean node rate) - Regex patterns like @@email_regexp = /\A[^@\s]+@[^@\s]+\z/ now parse Heredocs (#10) attempted but deferred — inline CompareOp < fires before external tokenizer can see <<. Needs a different approach. 76 tests passing (4 new regex tests).
1 parent 2d10330 commit 09fc1f1

5 files changed

Lines changed: 107 additions & 4 deletions

File tree

src/highlight.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const rubyHighlighting = styleTags({
2929
StringEscape: t.escape,
3030
InterpolationStart: t.special(t.brace),
3131
InterpolationEnd: t.special(t.brace),
32+
Regex: t.regexp,
3233
LineComment: t.lineComment,
3334
BlockComment: t.blockComment,
3435
"( )": t.paren,

src/syntax.grammar

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ expression {
182182
InterpolatedString |
183183
CharacterLiteral |
184184
PercentStringLiteral |
185+
Regex |
185186
Symbol |
186187
Array |
187188
Hash |
@@ -228,6 +229,7 @@ Arg {
228229
BinaryExpression {
229230
expression !add ArithOp expression |
230231
expression !add "-" expression |
232+
expression !mul divideOp expression |
231233
expression !mul "*" expression |
232234
expression !exp "**" expression |
233235
expression !compare CompareOp expression |
@@ -352,10 +354,9 @@ semi { ";" | newline }
352354
"%" $[wWiIqQrsx]? "{" (![\\}] | "\\" _)* "}" |
353355
"%" $[wWiIqQrsx]? "<" (![\\>] | "\\" _)* ">"
354356
}
355-
// Regex token removed — it conflicts with division (/). Regex support
356-
// requires an external tokenizer (Phase 4) that checks preceding context.
357-
358-
ArithOp { "+" | "/" | "%" }
357+
// "/" is NOT in ArithOp — it's handled by the external tokenizer which
358+
// emits either Regex or divideOp depending on context.
359+
ArithOp { "+" | "%" }
359360
CompareOp { "==" | "!=" | "<" | ">" | "<=" | ">=" | "<=>" | "===" }
360361
AssignOp { "=" | "+=" | "-=" | "*=" | "/=" | "%=" | "||=" | "&&=" | "<<=" | ">>=" }
361362
LogicOp { "&&" | "||" }
@@ -391,4 +392,9 @@ semi { ";" | newline }
391392
}
392393
}
393394

395+
// External tokenizers for context-dependent tokens
396+
// External tokenizer: handles / as either Regex or division (ArithOp)
397+
// based on parser state. Regex wins by longest-match when both are valid.
398+
@external tokens regexTokenizer from "./tokens" { Regex, divideOp[@name=ArithOp] }
399+
394400
@detectDelim

src/syntax.grammar.terms.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export declare const Regex: number
2+
export declare const divideOp: number

src/tokens.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {ExternalTokenizer} from "@lezer/lr"
2+
import {Regex, divideOp} from "./syntax.grammar.terms"
3+
4+
// ============================================================
5+
// Regex / Division external tokenizer (#7)
6+
//
7+
// `/` is ambiguous: regex literal or division operator.
8+
// This tokenizer handles both:
9+
//
10+
// 1. Try to match /pattern/flags (regex)
11+
// 2. If the parser state allows Regex AND the pattern is valid, emit Regex
12+
// 3. Otherwise emit divideOp (aliased to ArithOp) for division
13+
//
14+
// The parser state determines which is valid:
15+
// - At expression-start (after =, (, [, ,, operator): Regex is valid
16+
// - After a value (identifier, number, ), ]): only divideOp is valid
17+
// ============================================================
18+
19+
export const regexTokenizer = new ExternalTokenizer((input, stack) => {
20+
if (input.next !== 47 /* '/' */) return
21+
22+
// Try to match a regex pattern
23+
let pos = 1
24+
let isRegex = true
25+
while (true) {
26+
const ch = input.peek(pos)
27+
if (ch === -1 || ch === 10 /* \n */ || ch === 13 /* \r */) { isRegex = false; break }
28+
if (ch === 92 /* \\ */) {
29+
pos++
30+
// Check the escaped char isn't newline/EOF
31+
const next = input.peek(pos)
32+
if (next === -1 || next === 10 || next === 13) { isRegex = false; break }
33+
pos++
34+
continue
35+
}
36+
if (ch === 47 /* '/' */) { pos++; break }
37+
pos++
38+
}
39+
40+
if (isRegex) {
41+
// Consume optional flags: i, m, x, o, u, s, n
42+
while (true) {
43+
const ch = input.peek(pos)
44+
if (ch === 105 || ch === 109 || ch === 120 || ch === 111 ||
45+
ch === 117 || ch === 115 || ch === 110) {
46+
pos++
47+
} else {
48+
break
49+
}
50+
}
51+
52+
if (stack.canShift(Regex)) {
53+
input.acceptToken(Regex, pos)
54+
return
55+
}
56+
}
57+
58+
// Fall back to division
59+
if (stack.canShift(divideOp)) {
60+
input.acceptToken(divideOp, 1)
61+
}
62+
})

test/literals.txt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,35 @@ Program(ExpressionStatement(PercentStringLiteral))
133133
==>
134134

135135
Program(ExpressionStatement(PercentStringLiteral))
136+
137+
# Regex literal
138+
139+
/pattern/i
140+
141+
==>
142+
143+
Program(ExpressionStatement(Regex))
144+
145+
# Regex after assignment
146+
147+
x = /hello\sworld/m
148+
149+
==>
150+
151+
Program(Assignment(Identifier, AssignOp, Regex))
152+
153+
# Division not regex
154+
155+
10 / 2
156+
157+
==>
158+
159+
Program(ExpressionStatement(BinaryExpression(Integer, ArithOp, Integer)))
160+
161+
# Regex after operator
162+
163+
x + /test/
164+
165+
==>
166+
167+
Program(ExpressionStatement(BinaryExpression(Identifier, ArithOp, Regex)))

0 commit comments

Comments
 (0)