diff --git a/pkg/shparse/expand.go b/pkg/shparse/expand.go new file mode 100644 index 00000000..2f8d8910 --- /dev/null +++ b/pkg/shparse/expand.go @@ -0,0 +1,205 @@ +package shparse + +import ( + "bytes" + "fmt" + + "mvdan.cc/sh/v3/expand" +) + +const MaxExpandLen = 64 * 1024 + +type ExpandInfo struct { + HasTilde bool // only ~ as the first character when SimpleExpandContext.HomeDir is set + HasVar bool // $x, $$, ${...} + HasGlob bool // *, ?, [, { + HasExtGlob bool // ?(...) ... ?*+@! + HasHistory bool // ! (anywhere) + HasSpecial bool // subshell, arith +} + +type ExpandContext struct { + HomeDir string +} + +func expandSQ(buf *bytes.Buffer, rawLit []rune) { + // no info specials + buf.WriteString(string(rawLit)) +} + +// TODO implement our own ANSI single quote formatter +func expandANSISQ(buf *bytes.Buffer, rawLit []rune) { + // no info specials + str, _, _ := expand.Format(nil, string(rawLit), nil) + buf.WriteString(str) +} + +func expandLiteral(buf *bytes.Buffer, info *ExpandInfo, rawLit []rune) { + var lastBackSlash bool + var lastExtGlob bool + var lastDollar bool + for _, ch := range rawLit { + if ch == 0 { + break + } + if lastBackSlash { + lastBackSlash = false + if ch == '\n' { + // special case, backslash *and* newline are ignored + continue + } + buf.WriteRune(ch) + continue + } + if ch == '\\' { + lastBackSlash = true + lastExtGlob = false + lastDollar = false + continue + } + if ch == '*' || ch == '?' || ch == '[' || ch == '{' { + info.HasGlob = true + } + if ch == '`' { + info.HasSpecial = true + } + if ch == '!' { + info.HasHistory = true + } + if lastExtGlob && ch == '(' { + info.HasExtGlob = true + } + if lastDollar && (ch != ' ' && ch != '"' && ch != '\'' && ch != '(' || ch != '[') { + info.HasVar = true + } + if lastDollar && (ch == '(' || ch == '[') { + info.HasSpecial = true + } + lastExtGlob = (ch == '?' || ch == '*' || ch == '+' || ch == '@' || ch == '!') + lastDollar = (ch == '$') + buf.WriteRune(ch) + } + if lastBackSlash { + buf.WriteByte('\\') + } +} + +// will also work for partial double quoted strings +func expandDQLiteral(buf *bytes.Buffer, info *ExpandInfo, rawVal []rune) { + var lastBackSlash bool + var lastDollar bool + for _, ch := range rawVal { + if ch == 0 { + break + } + if lastBackSlash { + lastBackSlash = false + if ch == '"' || ch == '\\' || ch == '$' || ch == '`' { + buf.WriteRune(ch) + continue + } + buf.WriteRune('\\') + buf.WriteRune(ch) + continue + } + if ch == '\\' { + lastBackSlash = true + lastDollar = false + continue + } + + // similar to expandLiteral, but no globbing + if ch == '`' { + info.HasSpecial = true + } + if ch == '!' { + info.HasHistory = true + } + if lastDollar && (ch != ' ' && ch != '"' && ch != '\'' && ch != '(' || ch != '[') { + info.HasVar = true + } + if lastDollar && (ch == '(' || ch == '[') { + info.HasSpecial = true + } + lastDollar = (ch == '$') + buf.WriteRune(ch) + } + // in a valid parsed DQ string, you cannot have a trailing backslash (because \" would not end the string) + // still putting the case here though in case we ever deal with incomplete strings (e.g. completion) + if lastBackSlash { + buf.WriteByte('\\') + } +} + +func simpleExpandSubs(buf *bytes.Buffer, info *ExpandInfo, ectx ExpandContext, word *WordType, pos int) { + fmt.Printf("expand subs: %v\n", word) + parts := word.Subs + startPos := word.contentStartPos() + for _, part := range parts { + remainingLen := pos - startPos + if remainingLen <= 0 { + break + } + simpleExpandWord(buf, info, ectx, part, remainingLen) + startPos += len(part.Raw) + } +} + +func simpleExpandWord(buf *bytes.Buffer, info *ExpandInfo, ectx ExpandContext, word *WordType, pos int) { + if pos >= word.contentEndPos() { + pos = len(word.Raw) + } + if pos <= word.contentStartPos() { + return + } + + switch word.Type { + case WordTypeLit: + if word.QC.cur() == WordTypeDQ { + expandDQLiteral(buf, info, word.Raw[:pos]) + return + } + expandLiteral(buf, info, word.Raw[:pos]) + + case WordTypeSQ: + expandSQ(buf, word.Raw[word.contentStartPos():pos]) + return + + case WordTypeDSQ: + expandANSISQ(buf, word.Raw[word.contentStartPos():pos]) + return + + case WordTypeDQ, WordTypeDDQ: + simpleExpandSubs(buf, info, ectx, word, pos) + return + + case WordTypeGroup: + simpleExpandSubs(buf, info, ectx, word, pos) + return + + case WordTypeSimpleVar: + return + + case WordTypeVarBrace: + return + + default: + if pos > len(word.Raw) { + pos = len(word.Raw) + } + info.HasSpecial = true + buf.WriteString(string(word.Raw[:pos])) + return + } +} + +func SimpleExpandPrefix(ectx ExpandContext, word *WordType, pos int) (string, ExpandInfo) { + var buf bytes.Buffer + var info ExpandInfo + simpleExpandWord(&buf, &info, ectx, word, pos) + return buf.String(), info +} + +func SimpleExpand(ectx ExpandContext, word *WordType) (string, ExpandInfo) { + return SimpleExpandPrefix(ectx, word, len(word.Raw)) +} diff --git a/pkg/shparse/shparse.go b/pkg/shparse/shparse.go index 2a658bc6..e0f49eb5 100644 --- a/pkg/shparse/shparse.go +++ b/pkg/shparse/shparse.go @@ -188,9 +188,22 @@ func (w *WordType) isBlank() bool { return w.Type == WordTypeLit && len(w.Raw) == 0 } +func (w *WordType) contentEndPos() int { + if !w.Complete { + return len(w.Raw) + } + wmeta := wordMetaMap[w.Type] + return len(w.Raw) - wmeta.SuffixLen +} + +func (w *WordType) contentStartPos() int { + wmeta := wordMetaMap[w.Type] + return wmeta.PrefixLen +} + func (w *WordType) uncompletable() bool { switch w.Type { - case WordTypeRaw, WordTypeOp, WordTypeKey, WordTypeDPP, WordTypePP, WordTypeDB: + case WordTypeRaw, WordTypeOp, WordTypeKey, WordTypeDPP, WordTypePP, WordTypeDB, WordTypeBQ, WordTypeDP: return true default: diff --git a/pkg/shparse/shparse_test.go b/pkg/shparse/shparse_test.go index a445dd45..030c2ae5 100644 --- a/pkg/shparse/shparse_test.go +++ b/pkg/shparse/shparse_test.go @@ -157,3 +157,34 @@ func TestCompPos(t *testing.T) { testCompPos(t, `ls abc"$(ls $"echo $(ls ./[*]x) foo)" `, true, 1, true, false, false) } + +func testExpand(t *testing.T, str string, pos int, expStr string, expInfo *ExpandInfo) { + ectx := ExpandContext{HomeDir: "/Users/mike"} + words := Tokenize(str) + if len(words) == 0 { + t.Errorf("could not tokenize any words from %q", str) + return + } + word := words[0] + output, info := SimpleExpandPrefix(ectx, word, pos) + if output != expStr { + t.Errorf("error expanding %q, output:%q exp:%q", str, output, expStr) + } else { + fmt.Printf("expand: %q (%d) => %q\n", str, pos, output) + } + if expInfo != nil { + if info != *expInfo { + t.Errorf("error expanding %q, info:%v exp:%v", str, info, expInfo) + } + } +} + +func TestExpand(t *testing.T) { + testExpand(t, "hello", 3, "hel", nil) + testExpand(t, "he\\$xabc", 6, "he$xa", nil) + testExpand(t, "he${x}abc", 6, "he$xa", nil) + testExpand(t, "'hello\"mike'", 8, "hello\"m", nil) + testExpand(t, `$'abc\x01def`, 10, "abc\x01d", nil) + testExpand(t, `$((2 + 2))`, 6, "$((2 +", &ExpandInfo{HasSpecial: true}) + testExpand(t, `abc"def"`, 6, "abcde", nil) +}