Skip to content

Commit 8a449ba

Browse files
authored
fix: handle heredocs with trailing code (foo(<<~SQL), <<~HEREDOC.strip) (#25)
The heredoc tokenizer now uses a hybrid approach: - No trailing code: full token spanning opener + body + delimiter (existing behavior) - Trailing code after opener: opener-only token so the parser handles ), .method, etc. This fixes parse errors for patterns like foo(<<~SQL) and <<~HEREDOC.strip while keeping all 105 existing tests passing.
1 parent 3b7eb67 commit 8a449ba

2 files changed

Lines changed: 69 additions & 54 deletions

File tree

src/syntax.grammar

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -439,10 +439,11 @@ InterpolationEnd[openedBy=InterpolationStart] { "}" }
439439
@else StringContent
440440
}
441441

442-
// Heredocs are matched as a single opaque token by the external tokenizer.
443-
// The tokenizer reads <<[-~]?DELIM, scans to the closing DELIM line, and
444-
// emits the entire heredoc as one Heredoc token. Interpolation inside
445-
// heredocs is not yet supported (would need @local tokens + ContextTracker).
442+
// Heredocs: emitted as a single Heredoc token by lessThanTokenizer.
443+
// When there's no trailing code after the opener, the token spans the entire
444+
// heredoc (opener + body + closing delimiter). When there IS trailing code
445+
// (e.g. foo(<<~SQL) or <<~HEREDOC.strip), only the opener is emitted so
446+
// the parser can handle the trailing syntax correctly.
446447

447448
Array { "[" commaSep<expression> "]" }
448449
Hash { ~blockOrHash "{" (HashPair ("," HashPair)* ","?)? "}" ~blockOrHash }

src/tokens.ts

Lines changed: 64 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,15 @@ export const regexTokenizer = new ExternalTokenizer((input, stack) => {
5959
// ============================================================
6060
// Less-than / Heredoc external tokenizer (#10)
6161
//
62-
// `<` is ambiguous: comparison, `<=`, `<<` left shift, or heredoc start.
62+
// `<` is ambiguous: comparison, `<=`, `<<` left shift, or heredoc.
6363
// When `<<` is followed by [-~]?IDENTIFIER (or quoted string), and the
64-
// parser allows Heredoc, we scan to the matching closing delimiter
65-
// and emit the entire heredoc as one token.
64+
// parser allows Heredoc, we emit a Heredoc token.
65+
//
66+
// Two modes depending on trailing code after the opener:
67+
// 1. No trailing code: token spans opener + body + closing delimiter
68+
// 2. Trailing code (e.g. foo(<<~SQL) or <<~HEREDOC.strip): token spans
69+
// only the opener, allowing the parser to handle trailing syntax
70+
//
6671
// Otherwise emit lessThanOp or lessThanEqOp for comparison.
6772
// ============================================================
6873

@@ -74,9 +79,9 @@ export const lessThanTokenizer = new ExternalTokenizer((input, stack) => {
7479
// Try heredoc: <<
7580
if (second === 60 /* '<' */) {
7681
if (stack.canShift(Heredoc)) {
77-
const heredocLen = tryMatchHeredoc(input)
78-
if (heredocLen > 0) {
79-
input.acceptToken(Heredoc, heredocLen)
82+
const result = tryMatchHeredoc(input)
83+
if (result) {
84+
input.acceptToken(Heredoc, result)
8085
return
8186
}
8287
}
@@ -105,14 +110,21 @@ export const lessThanTokenizer = new ExternalTokenizer((input, stack) => {
105110
}
106111
})
107112

108-
// Try to match a complete heredoc starting at the current position.
109-
// Returns the total length including the closing delimiter, or 0 if no match.
110-
function tryMatchHeredoc(input: {peek(offset: number): number}): number {
113+
// Try to match a heredoc at the current position.
114+
// Returns the total token length, or null if no match.
115+
//
116+
// Two modes:
117+
// 1. No trailing code after opener: token spans opener + body + closing delimiter
118+
// e.g. <<~SQL\n SELECT 1\nSQL → entire thing is one token
119+
// 2. Trailing code after opener: token spans only the opener
120+
// e.g. <<~SQL in foo(<<~SQL) → just <<~SQL is the token
121+
function tryMatchHeredoc(input: {peek(offset: number): number}): number | null {
111122
let pos = 2 // past <<
112123

113124
// Optional - or ~
114125
const modifier = input.peek(pos)
115-
if (modifier === 45 /* - */ || modifier === 126 /* ~ */) pos++
126+
const indented = modifier === 45 /* - */ || modifier === 126 /* ~ */
127+
if (indented) pos++
116128

117129
// Read the delimiter
118130
let delimiter = ""
@@ -123,80 +135,82 @@ function tryMatchHeredoc(input: {peek(offset: number): number}): number {
123135
pos++
124136
while (true) {
125137
const ch = input.peek(pos)
126-
if (ch === -1 || ch === 10) return 0 // unterminated quote
138+
if (ch === -1 || ch === 10) return null // unterminated quote
127139
if (ch === quoteChar) { pos++; break }
128140
delimiter += String.fromCharCode(ch)
129141
pos++
130142
}
131143
} else {
132144
// Bare identifier delimiter: <<~DELIM
133-
if (!isIdentStart(input.peek(pos))) return 0
145+
if (!isIdentStart(input.peek(pos))) return null
134146
while (isIdentChar(input.peek(pos))) {
135147
delimiter += String.fromCharCode(input.peek(pos))
136148
pos++
137149
}
138150
}
139151

140-
if (!delimiter) return 0
152+
if (!delimiter) return null
153+
154+
const openerLength = pos // This is the length of just <<~DELIM
141155

142-
// After the delimiter, only whitespace/comments allowed on the rest of the line.
143-
// If there's code (like .method or (args)), this is << left-shift, not a heredoc.
156+
// Check what follows the delimiter on the same line.
157+
// For bare identifiers without a modifier, non-whitespace trailing code
158+
// means this is << operator, not heredoc. E.g. <<File.expand_path(...)
159+
// With a modifier (<<~ or <<-) or quoted delimiter, trailing code is OK.
160+
let scanPos = pos
161+
let hasTrailingCode = false
144162
while (true) {
145-
const ch = input.peek(pos)
146-
if (ch === -1) return 0
147-
if (ch === 10) { pos++; break }
148-
if (ch === 13) { pos++; if (input.peek(pos) === 10) pos++; break }
149-
if (ch === 32 || ch === 9) { pos++; continue } // whitespace OK
163+
const ch = input.peek(scanPos)
164+
if (ch === -1) return null // EOF on opener line, no body possible
165+
if (ch === 10) { scanPos++; break }
166+
if (ch === 13) { scanPos++; if (input.peek(scanPos) === 10) scanPos++; break }
167+
if (ch === 32 || ch === 9) { scanPos++; continue }
150168
if (ch === 35 /* # */) { // comment — skip rest of line
151169
while (true) {
152-
const c = input.peek(pos)
170+
const c = input.peek(scanPos)
153171
if (c === -1 || c === 10 || c === 13) break
154-
pos++
172+
scanPos++
155173
}
156174
continue
157175
}
158-
// For bare identifiers (not quoted), any non-whitespace after delimiter
159-
// means this is << operator, not heredoc. E.g. <<File.expand_path(...)
160-
if (quoteChar !== 39 && quoteChar !== 34 && quoteChar !== 96) return 0
161-
pos++ // quoted delimiters can have trailing content (e.g. <<~"SQL", other_arg)
176+
// Bare identifier without modifier + trailing code = not a heredoc
177+
if (quoteChar !== 39 && quoteChar !== 34 && quoteChar !== 96 &&
178+
!indented) return null
179+
hasTrailingCode = true
180+
scanPos++ // modifier/quoted → skip trailing code
162181
}
163182

164-
// Scan lines looking for the closing delimiter
165-
const isIndented = modifier === 45 || modifier === 126 // <<- or <<~
183+
// Verify the closing delimiter exists in the remaining input
184+
const bodyStart = scanPos
166185
while (true) {
167186
let lineContent = ""
168-
169-
// For indented heredocs (<<- or <<~), skip leading whitespace
170-
if (isIndented) {
171-
while (input.peek(pos) === 32 || input.peek(pos) === 9) pos++
187+
if (indented) {
188+
while (input.peek(scanPos) === 32 || input.peek(scanPos) === 9) scanPos++
172189
}
173-
174-
// Read the rest of the line
175190
while (true) {
176-
const ch = input.peek(pos)
191+
const ch = input.peek(scanPos)
177192
if (ch === -1 || ch === 10 || ch === 13) break
178193
lineContent += String.fromCharCode(ch)
179-
pos++
194+
scanPos++
180195
}
181-
182-
// Check if this line matches the delimiter (trimmed)
183196
if (lineContent === delimiter) {
184-
// Include the delimiter line in the token
185-
// Advance past newline if present
186-
const ch = input.peek(pos)
187-
if (ch === 10) pos++
188-
else if (ch === 13) { pos++; if (input.peek(pos) === 10) pos++ }
189-
return pos
197+
// Found closing delimiter
198+
if (hasTrailingCode) {
199+
// Trailing code present — emit only the opener so the parser
200+
// can handle ), .method, etc. on the same line
201+
return openerLength
202+
}
203+
// No trailing code — emit the full heredoc (opener + body + delimiter)
204+
return scanPos
190205
}
191-
192-
// Advance past newline
193-
const ch = input.peek(pos)
194-
if (ch === -1) return pos // unterminated heredoc — emit what we have
195-
if (ch === 10) pos++
196-
else if (ch === 13) { pos++; if (input.peek(pos) === 10) pos++ }
206+
const ch = input.peek(scanPos)
207+
if (ch === -1) return null // EOF without closing delimiter
208+
if (ch === 10) scanPos++
209+
else if (ch === 13) { scanPos++; if (input.peek(scanPos) === 10) scanPos++ }
197210
}
198211
}
199212

213+
200214
// ============================================================
201215
// Percent literal external tokenizer (#11)
202216
//

0 commit comments

Comments
 (0)