@@ -13,6 +13,7 @@ export function splitChainedCommand(command: string): string[] {
1313 let current = "" ;
1414 let quote : '"' | "'" | "`" | null = null ;
1515 let heredocMarker : string | null = null ;
16+ let heredocStripTabs = false ;
1617 let parenDepth = 0 ;
1718
1819 const push = ( ) : void => {
@@ -31,14 +32,16 @@ export function splitChainedCommand(command: string): string[] {
3132 const ch = command [ i ] as string ;
3233
3334 // Inside a heredoc body: scan for the terminating marker on its own line.
35+ // A second `<<` down here is payload, never a nested opener.
3436 if ( heredocMarker !== null ) {
3537 current += ch ;
3638 if ( ch === "\n" ) {
3739 // Check whether the line just completed is the marker.
3840 const lines = current . split ( "\n" ) ;
3941 const lastLine = lines [ lines . length - 2 ] ?? "" ;
40- if ( lastLine . trim ( ) === heredocMarker ) {
42+ if ( isHeredocTerminator ( lastLine , heredocMarker , heredocStripTabs ) ) {
4143 heredocMarker = null ;
44+ heredocStripTabs = false ;
4245 }
4346 }
4447 continue ;
@@ -75,6 +78,7 @@ export function splitChainedCommand(command: string): string[] {
7578 current += command . slice ( i , opener . lineEnd ) ;
7679 i = opener . lineEnd - 1 ;
7780 heredocMarker = opener . marker ;
81+ heredocStripTabs = opener . stripTabs ;
7882 continue ;
7983 }
8084 }
@@ -142,15 +146,16 @@ export function splitChainedCommand(command: string): string[] {
142146}
143147
144148// Parses a heredoc opener (`<<` or `<<-`) starting at `command[i]` (which must
145- // be the first "<"). Returns the terminating marker text and the exclusive end
146- // index of the line that opened the heredoc, so the caller can copy the
147- // opening line verbatim and resume scanning the heredoc body from there.
149+ // be the first "<"). Returns the terminating marker text, the exclusive end
150+ // index of the line that opened the heredoc, and whether the opener was `<<-`
151+ // (which strips leading tabs from the closing line) — so the caller can copy
152+ // the opening line verbatim and resume scanning the heredoc body from there.
148153// Shared by splitChainedCommand and stripCommentLines so both stay in sync on
149154// what counts as heredoc syntax.
150155export function parseHeredocOpener (
151156 command : string ,
152157 i : number ,
153- ) : { marker : string ; lineEnd : number } | null {
158+ ) : { marker : string ; lineEnd : number ; stripTabs : boolean } | null {
154159 if ( command [ i ] !== "<" || command [ i + 1 ] !== "<" ) return null ;
155160 // `<<<` is a here-string, not a heredoc: its word is an inline argument,
156161 // so there is no marker line to wait for.
@@ -162,7 +167,8 @@ export function parseHeredocOpener(
162167 // command as body.
163168 if ( command [ i - 1 ] === "<" ) return null ;
164169 let j = i + 2 ;
165- if ( command [ j ] === "-" ) j ++ ; // <<- strips leading tabs
170+ const stripTabs = command [ j ] === "-" ;
171+ if ( stripTabs ) j ++ ; // <<- strips leading tabs
166172 // Skip whitespace between << and the marker word.
167173 while ( j < command . length && ( command [ j ] === " " || command [ j ] === "\t" ) ) j ++ ;
168174 // The marker may be quoted ('EOF', "EOF", or bare EOF).
@@ -184,9 +190,27 @@ export function parseHeredocOpener(
184190 marker += command [ j ++ ] ;
185191 }
186192 if ( markerQuote !== null && command [ j ] === markerQuote ) j ++ ;
193+ // A CRLF opener line leaves a trailing \r on a bare marker word; the
194+ // terminator line carries the same \r, so drop it here and compare
195+ // CR-stripped lines at close time.
196+ if ( marker . endsWith ( "\r" ) ) marker = marker . slice ( 0 , - 1 ) ;
187197 // Advance j to the end of the line that opened the heredoc.
188198 while ( j < command . length && command [ j ] !== "\n" ) j ++ ;
189- return { marker, lineEnd : j } ;
199+ return { marker, lineEnd : j , stripTabs } ;
200+ }
201+
202+ // Whether a completed body line closes a heredoc: an exact match against the
203+ // marker, ignoring one trailing CR from CRLF input and leading tabs only when
204+ // the opener was `<<-`. A space-indented close never terminates a plain `<<`
205+ // heredoc — it stays body, exactly like a real shell.
206+ export function isHeredocTerminator (
207+ line : string ,
208+ marker : string ,
209+ stripTabs : boolean ,
210+ ) : boolean {
211+ const noCR = line . endsWith ( "\r" ) ? line . slice ( 0 , - 1 ) : line ;
212+ const candidate = stripTabs ? noCR . replace ( / ^ \t + / , "" ) : noCR ;
213+ return candidate === marker ;
190214}
191215
192216// Whether `text` ends (ignoring trailing whitespace) in a redirect operator
0 commit comments