Skip to content

Commit 09577c1

Browse files
authored
feat: add heredoc support via external tokenizer (#10) (#17)
Move < and <= out of CompareOp into an external tokenizer that also handles heredoc detection. When << is followed by [-~]?IDENTIFIER (or quoted string) and Heredoc is valid in the parser state, the tokenizer scans to the matching closing delimiter and emits the entire heredoc as one opaque token. Supports: - <<DELIM ... DELIM (strict) - <<-DELIM ... DELIM (indented closing) - <<~DELIM ... DELIM (squiggly — strips indent from closing match) - Quoted delimiters: <<~"DELIM", <<~'DELIM' Comparison operators still work: - a < b → CompareOp (via lessThanOp external token) - a <= b → CompareOp (via lessThanEqOp external token) Known limitations: - Heredoc body is one opaque token (no interpolation highlighting inside) - << left-shift operator not distinguished from heredoc start - <<~ indent stripping is delimiter-only (body whitespace preserved) 79 tests passing (3 new heredoc tests).
1 parent 09fc1f1 commit 09577c1

5 files changed

Lines changed: 183 additions & 19 deletions

File tree

src/highlight.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const rubyHighlighting = styleTags({
3030
InterpolationStart: t.special(t.brace),
3131
InterpolationEnd: t.special(t.brace),
3232
Regex: t.regexp,
33+
Heredoc: t.string,
3334
LineComment: t.lineComment,
3435
BlockComment: t.blockComment,
3536
"( )": t.paren,

src/syntax.grammar

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ expression {
183183
CharacterLiteral |
184184
PercentStringLiteral |
185185
Regex |
186+
Heredoc |
186187
Symbol |
187188
Array |
188189
Hash |
@@ -233,6 +234,8 @@ BinaryExpression {
233234
expression !mul "*" expression |
234235
expression !exp "**" expression |
235236
expression !compare CompareOp expression |
237+
expression !compare lessThanOp expression |
238+
expression !compare lessThanEqOp expression |
236239
expression !logic LogicOp expression |
237240
expression !logic kw<"and"> expression |
238241
expression !logic kw<"or"> expression
@@ -295,6 +298,11 @@ InterpolationEnd[openedBy=InterpolationStart] { "}" }
295298
@else StringContent
296299
}
297300

301+
// Heredocs are matched as a single opaque token by the external tokenizer.
302+
// The tokenizer reads <<[-~]?DELIM, scans to the closing DELIM line, and
303+
// emits the entire heredoc as one Heredoc token. Interpolation inside
304+
// heredocs is not yet supported (would need @local tokens + ContextTracker).
305+
298306
Array { "[" commaSep<expression> "]" }
299307
Hash { "{" (HashPair ("," HashPair)*)? "}" }
300308
HashPair { expression "=>" expression | Identifier ":" expression }
@@ -357,7 +365,9 @@ semi { ";" | newline }
357365
// "/" is NOT in ArithOp — it's handled by the external tokenizer which
358366
// emits either Regex or divideOp depending on context.
359367
ArithOp { "+" | "%" }
360-
CompareOp { "==" | "!=" | "<" | ">" | "<=" | ">=" | "<=>" | "===" }
368+
// "<" and "<=" are NOT in CompareOp — they're handled by the external
369+
// tokenizer which also emits heredocStart for <<DELIM.
370+
CompareOp { "==" | "!=" | ">" | ">=" | "<=>" | "===" }
361371
AssignOp { "=" | "+=" | "-=" | "*=" | "/=" | "%=" | "||=" | "&&=" | "<<=" | ">>=" }
362372
LogicOp { "&&" | "||" }
363373

@@ -393,8 +403,12 @@ semi { ";" | newline }
393403
}
394404

395405
// 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.
406+
// External tokenizers for context-dependent tokens
398407
@external tokens regexTokenizer from "./tokens" { Regex, divideOp[@name=ArithOp] }
408+
@external tokens lessThanTokenizer from "./tokens" {
409+
Heredoc,
410+
lessThanOp[@name=CompareOp],
411+
lessThanEqOp[@name=CompareOp]
412+
}
399413

400414
@detectDelim

src/syntax.grammar.terms.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
export declare const Regex: number
22
export declare const divideOp: number
3+
export declare const Heredoc: number
4+
export declare const lessThanOp: number
5+
export declare const lessThanEqOp: number

src/tokens.ts

Lines changed: 132 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,27 @@
11
import {ExternalTokenizer} from "@lezer/lr"
2-
import {Regex, divideOp} from "./syntax.grammar.terms"
2+
import {
3+
Regex, divideOp,
4+
Heredoc, lessThanOp, lessThanEqOp,
5+
} from "./syntax.grammar.terms"
36

47
// ============================================================
58
// Regex / Division external tokenizer (#7)
69
//
710
// `/` 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
11+
// Tries to match /pattern/flags; falls back to divideOp.
12+
// Parser state (stack.canShift) determines which is valid.
1713
// ============================================================
1814

1915
export const regexTokenizer = new ExternalTokenizer((input, stack) => {
2016
if (input.next !== 47 /* '/' */) return
2117

22-
// Try to match a regex pattern
2318
let pos = 1
2419
let isRegex = true
2520
while (true) {
2621
const ch = input.peek(pos)
27-
if (ch === -1 || ch === 10 /* \n */ || ch === 13 /* \r */) { isRegex = false; break }
22+
if (ch === -1 || ch === 10 || ch === 13) { isRegex = false; break }
2823
if (ch === 92 /* \\ */) {
2924
pos++
30-
// Check the escaped char isn't newline/EOF
3125
const next = input.peek(pos)
3226
if (next === -1 || next === 10 || next === 13) { isRegex = false; break }
3327
pos++
@@ -38,7 +32,7 @@ export const regexTokenizer = new ExternalTokenizer((input, stack) => {
3832
}
3933

4034
if (isRegex) {
41-
// Consume optional flags: i, m, x, o, u, s, n
35+
// Consume optional flags
4236
while (true) {
4337
const ch = input.peek(pos)
4438
if (ch === 105 || ch === 109 || ch === 120 || ch === 111 ||
@@ -48,15 +42,137 @@ export const regexTokenizer = new ExternalTokenizer((input, stack) => {
4842
break
4943
}
5044
}
51-
5245
if (stack.canShift(Regex)) {
5346
input.acceptToken(Regex, pos)
5447
return
5548
}
5649
}
5750

58-
// Fall back to division
5951
if (stack.canShift(divideOp)) {
6052
input.acceptToken(divideOp, 1)
6153
}
6254
})
55+
56+
// ============================================================
57+
// Less-than / Heredoc external tokenizer (#10)
58+
//
59+
// `<` is ambiguous: comparison, `<=`, `<<` left shift, or heredoc start.
60+
// When `<<` is followed by [-~]?IDENTIFIER (or quoted string), and the
61+
// parser allows Heredoc, we scan to the matching closing delimiter
62+
// and emit the entire heredoc as one token.
63+
// Otherwise emit lessThanOp or lessThanEqOp for comparison.
64+
// ============================================================
65+
66+
export const lessThanTokenizer = new ExternalTokenizer((input, stack) => {
67+
if (input.next !== 60 /* '<' */) return
68+
69+
const second = input.peek(1)
70+
71+
// Try heredoc: <<
72+
if (second === 60 /* '<' */ && stack.canShift(Heredoc)) {
73+
const heredocLen = tryMatchHeredoc(input)
74+
if (heredocLen > 0) {
75+
input.acceptToken(Heredoc, heredocLen)
76+
return
77+
}
78+
}
79+
80+
// <=
81+
if (second === 61 /* '=' */ && stack.canShift(lessThanEqOp)) {
82+
input.acceptToken(lessThanEqOp, 2)
83+
return
84+
}
85+
86+
// Plain <
87+
if (stack.canShift(lessThanOp)) {
88+
input.acceptToken(lessThanOp, 1)
89+
}
90+
})
91+
92+
// Try to match a complete heredoc starting at the current position.
93+
// Returns the total length including the closing delimiter, or 0 if no match.
94+
function tryMatchHeredoc(input: {peek(offset: number): number}): number {
95+
let pos = 2 // past <<
96+
97+
// Optional - or ~
98+
const modifier = input.peek(pos)
99+
if (modifier === 45 /* - */ || modifier === 126 /* ~ */) pos++
100+
101+
// Read the delimiter
102+
let delimiter = ""
103+
const quoteChar = input.peek(pos)
104+
105+
if (quoteChar === 39 /* ' */ || quoteChar === 34 /* " */ || quoteChar === 96 /* ` */) {
106+
// Quoted delimiter: <<~"DELIM"
107+
pos++
108+
while (true) {
109+
const ch = input.peek(pos)
110+
if (ch === -1 || ch === 10) return 0 // unterminated quote
111+
if (ch === quoteChar) { pos++; break }
112+
delimiter += String.fromCharCode(ch)
113+
pos++
114+
}
115+
} else {
116+
// Bare identifier delimiter: <<~DELIM
117+
if (!isIdentStart(input.peek(pos))) return 0
118+
while (isIdentChar(input.peek(pos))) {
119+
delimiter += String.fromCharCode(input.peek(pos))
120+
pos++
121+
}
122+
}
123+
124+
if (!delimiter) return 0
125+
126+
// Must be followed by newline (or end of opening line)
127+
// Skip to end of opening line
128+
while (true) {
129+
const ch = input.peek(pos)
130+
if (ch === -1) return 0 // no newline after heredoc start
131+
if (ch === 10) { pos++; break }
132+
if (ch === 13) { pos++; if (input.peek(pos) === 10) pos++; break }
133+
pos++
134+
}
135+
136+
// Scan lines looking for the closing delimiter
137+
const isIndented = modifier === 45 || modifier === 126 // <<- or <<~
138+
while (true) {
139+
let lineContent = ""
140+
141+
// For indented heredocs (<<- or <<~), skip leading whitespace
142+
if (isIndented) {
143+
while (input.peek(pos) === 32 || input.peek(pos) === 9) pos++
144+
}
145+
146+
// Read the rest of the line
147+
while (true) {
148+
const ch = input.peek(pos)
149+
if (ch === -1 || ch === 10 || ch === 13) break
150+
lineContent += String.fromCharCode(ch)
151+
pos++
152+
}
153+
154+
// Check if this line matches the delimiter (trimmed)
155+
if (lineContent === delimiter) {
156+
// Include the delimiter line in the token
157+
// Advance past newline if present
158+
const ch = input.peek(pos)
159+
if (ch === 10) pos++
160+
else if (ch === 13) { pos++; if (input.peek(pos) === 10) pos++ }
161+
return pos
162+
}
163+
164+
// Advance past newline
165+
const ch = input.peek(pos)
166+
if (ch === -1) return pos // unterminated heredoc — emit what we have
167+
if (ch === 10) pos++
168+
else if (ch === 13) { pos++; if (input.peek(pos) === 10) pos++ }
169+
}
170+
}
171+
172+
function isIdentStart(ch: number): boolean {
173+
return (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch === 95
174+
}
175+
176+
function isIdentChar(ch: number): boolean {
177+
return isIdentStart(ch) || (ch >= 48 && ch <= 57)
178+
}

test/literals.txt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,33 @@ x + /test/
165165
==>
166166

167167
Program(ExpressionStatement(BinaryExpression(Identifier, ArithOp, Regex)))
168+
169+
# Heredoc
170+
171+
<<~SQL
172+
SELECT * FROM users
173+
SQL
174+
175+
==>
176+
177+
Program(ExpressionStatement(Heredoc))
178+
179+
# Heredoc with assignment
180+
181+
x = <<~HTML
182+
<div>hello</div>
183+
HTML
184+
185+
==>
186+
187+
Program(Assignment(Identifier, AssignOp, Heredoc))
188+
189+
# Heredoc strict (no indent modifier)
190+
191+
<<RUBY
192+
hello
193+
RUBY
194+
195+
==>
196+
197+
Program(ExpressionStatement(Heredoc))

0 commit comments

Comments
 (0)