Skip to content
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ require (
golang.org/x/sys v0.43.0
golang.org/x/tools v0.44.0
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/sh/v3 v3.13.0
mvdan.cc/sh/v3 v3.13.1
)

require (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg=
mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
222 changes: 221 additions & 1 deletion interp/runner_expand.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,231 @@ func (r *Runner) expandErr(err error) {
}

func (r *Runner) fields(words ...*syntax.Word) []string {
strs, err := expand.Fields(r.ecfg, words...)
strs, err := expand.Fields(r.ecfg, protectEscapedLeftBraces(words)...)
r.expandErr(err)
return strs
}

// protectEscapedLeftBraces preserves bash's handling of backslash-quoted brace
// metacharacters before delegating to mvdan.cc/sh's field expansion.
//
// expand.Fields calls syntax.SplitBraces before quote removal. SplitBraces scans
// literal word parts for "{", ",", "..", and "}" without tracking whether a
// backslash quoted the byte, while bash uses that quote state when deciding
// whether a byte is brace syntax. Rewriting odd-backslash-escaped brace
// metacharacters as quoted word parts prevents brace expansion from seeing them
// while producing the same final text after quote removal.
func protectEscapedLeftBraces(words []*syntax.Word) []*syntax.Word {
var out []*syntax.Word
for i, word := range words {
protected := protectEscapedLeftBracesWord(word)
if protected != word && out == nil {
out = make([]*syntax.Word, len(words))
copy(out, words[:i])
}
if out != nil {
out[i] = protected
}
}
if out == nil {
return words
}
return out
}

func protectEscapedLeftBracesWord(word *syntax.Word) *syntax.Word {
if word == nil {
return nil
}
rightBraceQuotes := rightBraceQuotesAfterEscapedLeftBraces(word.Parts)
var parts []syntax.WordPart
for i, part := range word.Parts {
lit, ok := part.(*syntax.Lit)
if !ok {
if parts != nil {
parts = append(parts, part)
}
continue
}
litParts, changed := splitEscapedBraceMetasLit(lit, rightBraceQuotes[i])
if changed && parts == nil {
parts = make([]syntax.WordPart, 0, len(word.Parts)+len(litParts)-1)
parts = append(parts, word.Parts[:i]...)
}
if parts != nil {
parts = append(parts, litParts...)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unchanged literals after protected braces

When a word has a protected escaped brace before a non-literal part and then a normal literal, e.g. X=x; printf '<%s>\n' \{${X}foo, the first literal makes parts non-nil, but the later unchanged foo literal has changed == false and litParts == nil, so this append drops it. Bash expands that word to {xfoo}, while this commit returns {x}, so suffix literals after parameter/quoted/command substitutions are silently lost.

Useful? React with 👍 / 👎.

}
}
if parts == nil {
return word
}
protected := *word
protected.Parts = parts
return &protected
}

func rightBraceQuotesAfterEscapedLeftBraces(parts []syntax.WordPart) map[int]map[int]struct{} {
var quotes map[int]map[int]struct{}
for partIndex, part := range parts {
lit, ok := part.(*syntax.Lit)
if !ok || strings.Index(lit.Value, "\\{") < 0 {
continue
}
for i := 0; i < len(lit.Value); i++ {
if lit.Value[i] != '{' {
continue
}
slashStart := i
for slashStart > 0 && lit.Value[slashStart-1] == '\\' {
slashStart--
}
if (i-slashStart)%2 == 0 {
continue
}
quotePart, quoteOffset, ok := rightBraceToQuoteAfterEscapedLeftBrace(parts, partIndex, i+1)
if !ok {
continue
}
if quotes == nil {
quotes = make(map[int]map[int]struct{})
}
if quotes[quotePart] == nil {
quotes[quotePart] = make(map[int]struct{})
}
quotes[quotePart][quoteOffset] = struct{}{}
}
}
return quotes
}

func splitEscapedBraceMetasLit(lit *syntax.Lit, rightBraceQuotes map[int]struct{}) ([]syntax.WordPart, bool) {
s := lit.Value
if strings.Index(s, "\\") < 0 && len(rightBraceQuotes) == 0 {
return nil, false
}

var parts []syntax.WordPart
segmentStart := 0
appendLit := func(value string) {
if value == "" {
return
}
part := *lit
part.Value = value
parts = append(parts, &part)
}
appendProtected := func(start int, end int, part syntax.WordPart) {
if parts == nil {
parts = make([]syntax.WordPart, 0, 3)
}
appendLit(s[segmentStart:start])
parts = append(parts, part)
segmentStart = end
}

for i := 0; i < len(s); i++ {
if _, ok := rightBraceQuotes[i]; ok && s[i] == '}' {
appendProtected(i, i+1, &syntax.SglQuoted{Value: "}"})
continue
}
if !isBraceMetaByte(s[i]) {
continue
}
slashStart := i
for slashStart > segmentStart && s[slashStart-1] == '\\' {
slashStart--
}
slashCount := i - slashStart
if slashCount%2 == 0 {
continue
}
appendProtected(slashStart, i+1, &syntax.SglQuoted{
Value: escapedBraceMetaValue(slashCount, s[i]),
})
}

if parts == nil {
return nil, false
}
appendLit(s[segmentStart:])
return parts, true
}

func rightBraceToQuoteAfterEscapedLeftBrace(parts []syntax.WordPart, openPart int, openOffset int) (int, int, bool) {
openLit, ok := parts[openPart].(*syntax.Lit)
if !ok || openOffset >= len(openLit.Value) || openLit.Value[openOffset] != '{' {
return 0, 0, false
}

depth := 0
for partIndex := openPart; partIndex < len(parts); partIndex++ {
lit, ok := parts[partIndex].(*syntax.Lit)
if !ok {
continue
}
start := 0
if partIndex == openPart {
start = openOffset + 1
}
for i := start; i < len(lit.Value); i++ {
switch lit.Value[i] {
case '{':
if countBackslashesBefore(lit.Value, i)%2 == 0 {
depth++
}
case ',':
Comment thread
AlexandreYang marked this conversation as resolved.
if countBackslashesBefore(lit.Value, i)%2 == 0 && depth == 0 {
return 0, 0, false
}
case '.':
if countBackslashesBefore(lit.Value, i)%2 == 0 && depth == 0 && i+1 < len(lit.Value) && lit.Value[i+1] == '.' {
return 0, 0, false
}
case '}':
if countBackslashesBefore(lit.Value, i)%2 != 0 {
continue
}
if depth == 0 {
return partIndex, i, true
}
depth--
}
}
}
return 0, 0, false
}

func countBackslashesBefore(s string, i int) int {
count := 0
for i > 0 && s[i-1] == '\\' {
count++
i--
}
return count
}

func isBraceMetaByte(b byte) bool {
switch b {
case '{', ',', '.', '}':
return true
default:
return false
}
}

func escapedBraceMetaValue(slashCount int, meta byte) string {
quotedSlashCount := slashCount / 2
if quotedSlashCount == 0 {
return string(meta)
}
var b strings.Builder
for i := 0; i < quotedSlashCount; i++ {
b.WriteByte('\\')
}
b.WriteByte(meta)
return b.String()
}

func (r *Runner) literal(word *syntax.Word) string {
str, err := expand.Literal(r.ecfg, word)
r.expandErr(err)
Expand Down
Loading
Loading