Skip to content

Commit 50de773

Browse files
authored
feat: move %-literals to external tokenizer with full delimiter support (#11) (#19)
Replace inline PercentStringLiteral token with external tokenizer that: - Supports ALL delimiters, not just brackets: %w|a b|, %q!hello!, etc. - Handles paired bracket nesting: %Q(foo(bar)) tracks depth correctly - Disambiguates % as modulo vs percent literal using stack.canShift() - Rejects alphanumeric characters as delimiters (prevents %3 misparse) Same pattern as regex/division and less-than/heredoc tokenizers: try the longer match (percent literal) first, fall back to the single-char operator (moduloOp aliased to ArithOp). 83 tests passing (3 new: pipe delimiter, exclamation delimiter, modulo).
1 parent 0743fe4 commit 50de773

4 files changed

Lines changed: 116 additions & 11 deletions

File tree

src/syntax.grammar

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ Arg {
238238
BinaryExpression {
239239
expression !add ArithOp expression |
240240
expression !add "-" expression |
241+
expression !mul moduloOp expression |
241242
expression !mul divideOp expression |
242243
expression !mul "*" expression |
243244
expression !exp "**" expression |
@@ -364,18 +365,13 @@ semi { ";" | newline }
364365
Symbol { ":" $[a-zA-Z_] $[a-zA-Z0-9_]* }
365366
CharacterLiteral { "?" ("\\" _ | ![$ \t\n\r]) }
366367

367-
// %-literals: %w[a b], %i[foo], %q(str), %Q(str), %r(regex), %()
368-
// Supports [] () {} <> delimiters. Other delimiters (e.g. %w|a b|) need
369-
// an external tokenizer and are deferred.
370-
PercentStringLiteral {
371-
"%" $[wWiIqQrsx]? "[" (![\\\]] | "\\" _)* "]" |
372-
"%" $[wWiIqQrsx]? "(" (![\\)] | "\\" _)* ")" |
373-
"%" $[wWiIqQrsx]? "{" (![\\}] | "\\" _)* "}" |
374-
"%" $[wWiIqQrsx]? "<" (![\\>] | "\\" _)* ">"
375-
}
368+
// %-literals are now handled by the external tokenizer to support
369+
// all delimiters (not just brackets) and proper content scanning.
376370
// "/" is NOT in ArithOp — it's handled by the external tokenizer which
377371
// emits either Regex or divideOp depending on context.
378-
ArithOp { "+" | "%" }
372+
// "%" is NOT in ArithOp — handled by external tokenizer which emits
373+
// either PercentStringLiteral or moduloOp depending on context.
374+
ArithOp { "+" }
379375
// "<" and "<=" are NOT in CompareOp — they're handled by the external
380376
// tokenizer which also emits heredocStart for <<DELIM.
381377
CompareOp { "==" | "!=" | ">" | ">=" | "<=>" | "===" }
@@ -396,7 +392,6 @@ semi { ";" | newline }
396392
Identifier,
397393
Symbol,
398394
CharacterLiteral,
399-
PercentStringLiteral,
400395
CompareOp,
401396
"::",
402397
"=>",
@@ -416,6 +411,7 @@ semi { ";" | newline }
416411
// External tokenizers for context-dependent tokens
417412
// External tokenizers for context-dependent tokens
418413
@external tokens regexTokenizer from "./tokens" { Regex, divideOp[@name=ArithOp] }
414+
@external tokens percentLiteralTokenizer from "./tokens" { PercentStringLiteral, moduloOp[@name=ArithOp] }
419415
@external tokens lessThanTokenizer from "./tokens" {
420416
Heredoc,
421417
lessThanOp[@name=CompareOp],

src/syntax.grammar.terms.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ export declare const Heredoc: number
44
export declare const lessThanOp: number
55
export declare const lessThanEqOp: number
66
export declare const inheritsOp: number
7+
export declare const PercentStringLiteral: number
8+
export declare const moduloOp: number

src/tokens.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {ExternalTokenizer} from "@lezer/lr"
22
import {
33
Regex, divideOp,
44
Heredoc, lessThanOp, lessThanEqOp, inheritsOp,
5+
PercentStringLiteral, moduloOp,
56
} from "./syntax.grammar.terms"
67

78
// ============================================================
@@ -175,6 +176,88 @@ function tryMatchHeredoc(input: {peek(offset: number): number}): number {
175176
}
176177
}
177178

179+
// ============================================================
180+
// Percent literal external tokenizer (#11)
181+
//
182+
// Matches %w[], %i[], %q(), %Q(), %r(), %x(), %s(), %(), and
183+
// any single-character delimiter: %w|a b|, %q!hello!, etc.
184+
//
185+
// Bracket delimiters ([], (), {}, <>) are paired — the tokenizer
186+
// tracks nesting depth. Non-bracket delimiters use the same
187+
// character for open and close.
188+
// ============================================================
189+
190+
const BRACKET_PAIRS: Record<number, number> = {
191+
91: 93, // [ → ]
192+
40: 41, // ( → )
193+
123: 125, // { → }
194+
60: 62, // < → >
195+
}
196+
197+
export const percentLiteralTokenizer = new ExternalTokenizer((input, stack) => {
198+
if (input.next !== 37 /* '%' */) return
199+
200+
// Try to match a percent literal
201+
const literalLen = tryMatchPercentLiteral(input)
202+
if (literalLen > 0 && stack.canShift(PercentStringLiteral)) {
203+
input.acceptToken(PercentStringLiteral, literalLen)
204+
return
205+
}
206+
207+
// Fall back to modulo operator
208+
if (stack.canShift(moduloOp)) {
209+
input.acceptToken(moduloOp, 1)
210+
}
211+
})
212+
213+
function tryMatchPercentLiteral(input: {peek(offset: number): number}): number {
214+
let pos = 1
215+
const modifier = input.peek(pos)
216+
217+
// Optional type modifier: w W i I q Q r x s
218+
if (modifier >= 65 && modifier <= 90 || modifier >= 97 && modifier <= 122) {
219+
const valid = [119, 87, 105, 73, 113, 81, 114, 120, 115] // w W i I q Q r x s
220+
if (valid.indexOf(modifier) === -1) return 0
221+
pos++
222+
}
223+
224+
// Read the delimiter character
225+
const openDelim = input.peek(pos)
226+
// Reject whitespace, EOF, and alphanumeric characters as delimiters.
227+
// Digits would cause %3 or %300 to be misread as percent literals.
228+
if (openDelim === -1 || openDelim === 32 || openDelim === 9 ||
229+
openDelim === 10 || openDelim === 13 ||
230+
(openDelim >= 48 && openDelim <= 57) || // 0-9
231+
(openDelim >= 65 && openDelim <= 90) || // A-Z
232+
(openDelim >= 97 && openDelim <= 122)) return 0 // a-z
233+
234+
// Don't match %letter that isn't followed by a delimiter (e.g. % in arithmetic)
235+
pos++
236+
const closeDelim = BRACKET_PAIRS[openDelim] || openDelim
237+
238+
if (BRACKET_PAIRS[openDelim]) {
239+
let depth = 1
240+
while (depth > 0) {
241+
const ch = input.peek(pos)
242+
if (ch === -1) return pos // unterminated
243+
if (ch === 92 /* \\ */) { pos += 2; continue }
244+
if (ch === openDelim) depth++
245+
if (ch === closeDelim) depth--
246+
pos++
247+
}
248+
} else {
249+
while (true) {
250+
const ch = input.peek(pos)
251+
if (ch === -1) return pos // unterminated
252+
if (ch === 92 /* \\ */) { pos += 2; continue }
253+
if (ch === closeDelim) { pos++; break }
254+
pos++
255+
}
256+
}
257+
258+
return pos
259+
}
260+
178261
function isIdentStart(ch: number): boolean {
179262
return (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch === 95
180263
}

test/literals.txt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,27 @@ RUBY
187187
==>
188188

189189
Program(ExpressionStatement(Heredoc))
190+
191+
# Percent literal with pipe delimiter
192+
193+
%w|foo bar baz|
194+
195+
==>
196+
197+
Program(ExpressionStatement(PercentStringLiteral))
198+
199+
# Percent literal with exclamation delimiter
200+
201+
%q!hello world!
202+
203+
==>
204+
205+
Program(ExpressionStatement(PercentStringLiteral))
206+
207+
# Modulo operator (not percent literal)
208+
209+
10 % 3
210+
211+
==>
212+
213+
Program(ExpressionStatement(BinaryExpression(Integer, ArithOp, Integer)))

0 commit comments

Comments
 (0)