@@ -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