@@ -635,33 +635,104 @@ export async function expandWordWithGlob(
635635 }
636636 }
637637
638- // Handle unquoted $@ and $* specially - they should expand to individual args
639- // without IFS-based word splitting
640- if ( ! hasQuoted && hasArrayVar ) {
641- // Check if this is purely $@ or $* (not part of a larger word)
642- if ( wordParts . length === 1 && wordParts [ 0 ] . type === "ParameterExpansion" ) {
643- const param = wordParts [ 0 ] . parameter ;
644- if ( param === "@" || param === "*" ) {
645- // Get individual positional parameters
646- const numParams = Number . parseInt ( ctx . state . env [ "#" ] || "0" , 10 ) ;
647- if ( numParams === 0 ) {
648- return { values : [ ] , quoted : false } ;
649- }
650- const params : string [ ] = [ ] ;
651- for ( let i = 1 ; i <= numParams ; i ++ ) {
652- params . push ( ctx . state . env [ String ( i ) ] || "" ) ;
638+ // Handle "$@" and "$*" with adjacent text inside double quotes, e.g., "-$@-"
639+ // "$@": Each positional parameter becomes a separate word, with prefix joined to first
640+ // and suffix joined to last. If no params, produces nothing (or just prefix+suffix if present)
641+ // "$*": All params joined with IFS as ONE word. If no params, produces one empty word.
642+ if ( wordParts . length === 1 && wordParts [ 0 ] . type === "DoubleQuoted" ) {
643+ const dqPart = wordParts [ 0 ] ;
644+ // Find if there's a $@ or $* inside
645+ let atIndex = - 1 ;
646+ let isStar = false ;
647+ for ( let i = 0 ; i < dqPart . parts . length ; i ++ ) {
648+ const p = dqPart . parts [ i ] ;
649+ if (
650+ p . type === "ParameterExpansion" &&
651+ ( p . parameter === "@" || p . parameter === "*" )
652+ ) {
653+ atIndex = i ;
654+ isStar = p . parameter === "*" ;
655+ break ;
656+ }
657+ }
658+
659+ if ( atIndex !== - 1 ) {
660+ // Check if this is a simple $@ or $* without operations like ${*-default}
661+ const paramPart = dqPart . parts [ atIndex ] ;
662+ if ( paramPart . type === "ParameterExpansion" && paramPart . operation ) {
663+ // Has an operation - let normal expansion handle it
664+ atIndex = - 1 ;
665+ }
666+ }
667+
668+ if ( atIndex !== - 1 ) {
669+ // Get positional parameters
670+ const numParams = Number . parseInt ( ctx . state . env [ "#" ] || "0" , 10 ) ;
671+
672+ // Expand prefix (parts before $@/$*)
673+ let prefix = "" ;
674+ for ( let i = 0 ; i < atIndex ; i ++ ) {
675+ prefix += await expandPart ( ctx , dqPart . parts [ i ] ) ;
676+ }
677+
678+ // Expand suffix (parts after $@/$*)
679+ let suffix = "" ;
680+ for ( let i = atIndex + 1 ; i < dqPart . parts . length ; i ++ ) {
681+ suffix += await expandPart ( ctx , dqPart . parts [ i ] ) ;
682+ }
683+
684+ if ( numParams === 0 ) {
685+ if ( isStar ) {
686+ // "$*" with no params -> one empty word (prefix + suffix)
687+ return { values : [ prefix + suffix ] , quoted : true } ;
653688 }
654- return { values : params , quoted : false } ;
689+ // "$@" with no params -> no words (unless there's prefix/suffix)
690+ const combined = prefix + suffix ;
691+ return { values : combined ? [ combined ] : [ ] , quoted : true } ;
692+ }
693+
694+ // Get individual positional parameters
695+ const params : string [ ] = [ ] ;
696+ for ( let i = 1 ; i <= numParams ; i ++ ) {
697+ params . push ( ctx . state . env [ String ( i ) ] || "" ) ;
655698 }
699+
700+ if ( isStar ) {
701+ // "$*" - join all params with IFS into one word
702+ const ifsSep = getIfsSeparator ( ctx . state . env ) ;
703+ return {
704+ values : [ prefix + params . join ( ifsSep ) + suffix ] ,
705+ quoted : true ,
706+ } ;
707+ }
708+
709+ // "$@" - each param is a separate word
710+ // Join prefix with first, suffix with last
711+ if ( params . length === 1 ) {
712+ return { values : [ prefix + params [ 0 ] + suffix ] , quoted : true } ;
713+ }
714+
715+ const result = [
716+ prefix + params [ 0 ] ,
717+ ...params . slice ( 1 , - 1 ) ,
718+ params [ params . length - 1 ] + suffix ,
719+ ] ;
720+ return { values : result , quoted : true } ;
656721 }
657722 }
658723
724+ // Note: Unquoted $@ and $* are handled by normal expansion + word splitting.
725+ // They expand to positional parameters joined by space, then split on IFS.
726+ // The special handling above is only for quoted "$@" and "$*" inside double quotes.
727+
659728 // No brace expansion or single value - use original logic
660729 // Word splitting based on IFS
661730 // If IFS is set to empty string, no word splitting occurs
662731 // Word splitting applies to results of parameter expansion, command substitution, and arithmetic expansion
732+ // Note: hasQuoted being true does NOT prevent word splitting - unquoted expansions like $a in $a"$b"
733+ // should still be split. The smartWordSplit function handles this by treating quoted parts as
734+ // non-splittable segments that join with adjacent fields.
663735 if (
664- ! hasQuoted &&
665736 ( hasCommandSub || hasArrayVar || hasParamExpansion ) &&
666737 ! isIfsEmpty ( ctx . state . env )
667738 ) {
0 commit comments