@@ -319,8 +319,10 @@ pub(crate) fn strip_quoted_heredoc_bodies(command: &str) -> Cow<'_, str> {
319319
320320 let mut pending = std:: collections:: VecDeque :: new ( ) ;
321321 let mut out = String :: with_capacity ( command. len ( ) ) ;
322- let mut quote = QuoteState :: None ;
323- let mut token_boundary = true ;
322+ let mut header = HeredocHeader {
323+ quote : QuoteState :: None ,
324+ token_boundary : true ,
325+ } ;
324326 let mut reading_bodies = false ;
325327 for line in command. split_inclusive ( '\n' ) {
326328 if reading_bodies && let Some ( ( delim, strip_tabs, quoted) ) = pending. front ( ) {
@@ -343,103 +345,123 @@ pub(crate) fn strip_quoted_heredoc_bodies(command: &str) -> Cow<'_, str> {
343345 continue ;
344346 }
345347 out. push_str ( line) ;
346- let mut chars = line. char_indices ( ) . peekable ( ) ;
347- while let Some ( ( idx, ch) ) = chars. next ( ) {
348- match quote {
349- QuoteState :: Single => {
350- if ch == '\'' {
351- quote = QuoteState :: None ;
352- }
348+ let Some ( logical_newline) = scan_heredoc_header ( line, & mut header, & mut pending) else {
349+ return Cow :: Borrowed ( command) ;
350+ } ;
351+ reading_bodies = logical_newline && !pending. is_empty ( ) ;
352+ }
353+ if out == command {
354+ Cow :: Borrowed ( command)
355+ } else {
356+ Cow :: Owned ( out)
357+ }
358+ }
359+
360+ struct HeredocHeader {
361+ quote : QuoteState ,
362+ token_boundary : bool ,
363+ }
364+
365+ // Physical lines can continue a word or quote. Only an unquoted, unescaped
366+ // newline ends the command header and starts consuming its ordered bodies.
367+ fn scan_heredoc_header (
368+ line : & str ,
369+ header : & mut HeredocHeader ,
370+ pending : & mut std:: collections:: VecDeque < ( String , bool , bool ) > ,
371+ ) -> Option < bool > {
372+ let mut logical_newline = false ;
373+ let mut chars = line. char_indices ( ) . peekable ( ) ;
374+ while let Some ( ( idx, ch) ) = chars. next ( ) {
375+ match header. quote {
376+ QuoteState :: Single => {
377+ if ch == '\'' {
378+ header. quote = QuoteState :: None ;
353379 }
354- QuoteState :: Double => match ch {
355- // POSIX double quotes escape only these characters. Consuming
356- // a continued newline leaves the command and quote open.
357- '\\' if chars
358- . peek ( )
359- . is_some_and ( | ( _ , c ) | matches ! ( c , '$' | '`' | '"' | '\\' | '\n' ) ) =>
360- {
361- chars . next ( ) ;
362- }
363- '"' => quote = QuoteState :: None ,
364- '` ' => return Cow :: Borrowed ( command ) ,
365- '$' if chars . peek ( ) . is_some_and ( | ( _ , c ) | * c == '{' ) => {
366- return Cow :: Borrowed ( command ) ;
367- }
368- '$' if chars . peek ( ) . is_some_and ( | ( _ , c ) | * c == '(' ) => {
369- if ! skip_simple_substitution ( & mut chars ) {
370- return Cow :: Borrowed ( command ) ;
371- }
372- }
373- _ => { }
374- } ,
375- QuoteState :: None => match ch {
376- '\\' => {
377- if let Some ( ( _ , next ) ) = chars . next ( ) {
378- // Backslash-newline disappears before tokenization:
379- // neither a token boundary nor a body boundary.
380- if next != '\n' {
381- token_boundary = false ;
382- }
380+ }
381+ QuoteState :: Double => match ch {
382+ // POSIX double quotes escape only these characters. Consuming
383+ // a continued newline leaves the command and quote open.
384+ '\\' if chars
385+ . peek ( )
386+ . is_some_and ( | ( _ , c ) | matches ! ( c , '$' | '`' | '"' | '\\' | '\n' ) ) =>
387+ {
388+ chars . next ( ) ;
389+ }
390+ '" ' => header . quote = QuoteState :: None ,
391+ '`' => return None ,
392+ '$' if chars . peek ( ) . is_some_and ( | ( _ , c ) | * c == '{' ) => {
393+ return None ;
394+ }
395+ '$' if chars . peek ( ) . is_some_and ( | ( _ , c ) | * c == '(' )
396+ && ! skip_simple_substitution ( & mut chars ) =>
397+ {
398+ return None ;
399+ }
400+ _ => { }
401+ } ,
402+ QuoteState :: None => match ch {
403+ '\\' => {
404+ if let Some ( ( _ , next ) ) = chars . next ( ) {
405+ // Backslash-newline disappears before tokenization:
406+ // neither a token boundary nor a body boundary.
407+ if next != '\n' {
408+ header . token_boundary = false ;
383409 }
384410 }
385- '\'' => {
386- quote = QuoteState :: Single ;
387- token_boundary = false ;
388- }
389- '"' => {
390- quote = QuoteState :: Double ;
391- token_boundary = false ;
392- }
393- '#' if token_boundary => {
394- // Backslashes and quotes inside a comment are just data.
395- reading_bodies = line. ends_with ( '\n' ) && !pending. is_empty ( ) ;
396- token_boundary = true ;
397- break ;
398- }
399- '\n' => {
400- reading_bodies = !pending. is_empty ( ) ;
401- token_boundary = true ;
402- }
403- '<' if chars. next_if ( |( _, c) | * c == '<' ) . is_some ( ) => {
404- if chars. next_if ( |( _, c) | * c == '<' ) . is_some ( ) {
405- token_boundary = true ;
406- continue ;
407- }
408- let rest = & line[ idx + 2 ..] ;
409- let Some ( ( delim, consumed, quoted) ) = heredoc_delimiter ( rest) else {
410- // Unsupported quote/expansion syntax cannot safely
411- // establish where any queued body ends.
412- return Cow :: Borrowed ( command) ;
413- } ;
414- pending. push_back ( ( delim, rest. starts_with ( '-' ) , quoted) ) ;
415- for _ in 0 ..consumed {
416- chars. next ( ) ;
417- }
418- token_boundary = false ;
411+ }
412+ '\'' => {
413+ header. quote = QuoteState :: Single ;
414+ header. token_boundary = false ;
415+ }
416+ '"' => {
417+ header. quote = QuoteState :: Double ;
418+ header. token_boundary = false ;
419+ }
420+ '#' if header. token_boundary => {
421+ // Backslashes and quotes inside a comment are just data.
422+ logical_newline = line. ends_with ( '\n' ) && !pending. is_empty ( ) ;
423+ header. token_boundary = true ;
424+ break ;
425+ }
426+ '\n' => {
427+ logical_newline = !pending. is_empty ( ) ;
428+ header. token_boundary = true ;
429+ }
430+ '<' if chars. next_if ( |( _, c) | * c == '<' ) . is_some ( ) => {
431+ if chars. next_if ( |( _, c) | * c == '<' ) . is_some ( ) {
432+ header. token_boundary = true ;
433+ continue ;
419434 }
420- '`' => return Cow :: Borrowed ( command) ,
421- '$' if chars
422- . peek ( )
423- . is_some_and ( |( _, c) | matches ! ( c, '{' | '\'' | '"' ) ) =>
424- {
425- return Cow :: Borrowed ( command) ;
435+ let rest = & line[ idx + 2 ..] ;
436+ let Some ( ( delim, consumed, quoted) ) = heredoc_delimiter ( rest) else {
437+ // Unsupported quote/expansion syntax cannot safely
438+ // establish where any queued body ends.
439+ return None ;
440+ } ;
441+ pending. push_back ( ( delim, rest. starts_with ( '-' ) , quoted) ) ;
442+ for _ in 0 ..consumed {
443+ chars. next ( ) ;
426444 }
427- '$' if chars. peek ( ) . is_some_and ( |( _, c) | * c == '(' ) => {
428- if !skip_simple_substitution ( & mut chars) {
429- return Cow :: Borrowed ( command) ;
430- }
431- token_boundary = false ;
445+ header. token_boundary = false ;
446+ }
447+ '`' => return None ,
448+ '$' if chars
449+ . peek ( )
450+ . is_some_and ( |( _, c) | matches ! ( c, '{' | '\'' | '"' ) ) =>
451+ {
452+ return None ;
453+ }
454+ '$' if chars. peek ( ) . is_some_and ( |( _, c) | * c == '(' ) => {
455+ if !skip_simple_substitution ( & mut chars) {
456+ return None ;
432457 }
433- _ => token_boundary = ch. is_whitespace ( ) || ";&|<>()" . contains ( ch) ,
434- } ,
435- }
458+ header. token_boundary = false ;
459+ }
460+ _ => header. token_boundary = ch. is_whitespace ( ) || ";&|<>()" . contains ( ch) ,
461+ } ,
436462 }
437463 }
438- if out == command {
439- Cow :: Borrowed ( command)
440- } else {
441- Cow :: Owned ( out)
442- }
464+ Some ( logical_newline)
443465}
444466
445467// Skip only a flat command substitution whose text cannot declare a heredoc or
@@ -449,7 +471,7 @@ fn skip_simple_substitution(chars: &mut std::iter::Peekable<std::str::CharIndice
449471 for ( _, ch) in chars. by_ref ( ) {
450472 match ch {
451473 ')' => return true ,
452- '\n' | '\\' | '\'' | '"' | '`' | '$' | '<' | '(' => return false ,
474+ '\n' | '\\' | '\'' | '"' | '`' | '$' | '<' | '(' | '#' => return false ,
453475 _ => { }
454476 }
455477 }
0 commit comments