diff --git a/pkg/shparse/shparse.go b/pkg/shparse/shparse.go index 601e983c..2a658bc6 100644 --- a/pkg/shparse/shparse.go +++ b/pkg/shparse/shparse.go @@ -166,6 +166,13 @@ func (qc QuoteContext) cur() string { return qc[len(qc)-1] } +func (qc QuoteContext) clone() QuoteContext { + if len(qc) == 0 { + return nil + } + return append([]string(nil), qc...) +} + func makeRepeatStr(ch byte, slen int) string { if slen == 0 { return "" @@ -184,10 +191,10 @@ func (w *WordType) isBlank() bool { func (w *WordType) uncompletable() bool { switch w.Type { case WordTypeRaw, WordTypeOp, WordTypeKey, WordTypeDPP, WordTypePP, WordTypeDB: - return false + return true default: - return true + return false } } @@ -240,9 +247,9 @@ func dumpWords(words []*WordType, indentStr string, offset int) { func dumpCommands(cmds []*CmdType, indentStr string, pos int) { for _, cmd := range cmds { - fmt.Printf("%sCMD: %s [%d]\n", indentStr, cmd.Type, len(cmd.Words)) - dumpWords(cmd.AssignmentWords, indentStr+" *", -1) - dumpWords(cmd.Words, indentStr+" ", -1) + fmt.Printf("%sCMD: %s [%d] pos:%d\n", indentStr, cmd.Type, len(cmd.Words), pos) + dumpWords(cmd.AssignmentWords, indentStr+" *", pos) + dumpWords(cmd.Words, indentStr+" ", pos) } } @@ -487,8 +494,9 @@ func identifyReservedWords(words []*WordType) { } type CompletionPos struct { - RawPos int // the raw position of cursor - Cmd *CmdType // nil if between commands (otherwise will be a SimpleCommand) + RawPos int // the raw position of cursor + SuperOffset int // adjust all offsets in Cmd and CmdWord by SuperOffset + Cmd *CmdType // nil if between commands (otherwise will be a SimpleCommand) // index into cmd.Words (only useful when Cmd is not nil, otherwise we look at CompCommand) // 0 means command-word @@ -502,7 +510,7 @@ type CompletionPos struct { CompCommand bool // set when we think we are the first word of an existing or new command. otherwise we default to file completion } -func (cmd *CmdType) findCompletionPos_simple(pos int) CompletionPos { +func (cmd *CmdType) findCompletionPos_simple(pos int, superOffset int) CompletionPos { if cmd.Type != CmdTypeSimple { panic("findCompletetionPos_simple only works for CmdTypeSimple") } @@ -511,11 +519,11 @@ func (cmd *CmdType) findCompletionPos_simple(pos int) CompletionPos { endOffset := word.Offset + len(word.Raw) if pos <= startOffset { // starting a new word at this position (before the current assignment word) - return CompletionPos{RawPos: pos, Cmd: cmd, CmdWordPos: idx - len(cmd.AssignmentWords) - 1} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, Cmd: cmd, CmdWordPos: idx - len(cmd.AssignmentWords) - 1} } if pos <= endOffset { // completing an assignment word - return CompletionPos{RawPos: pos, Cmd: cmd, CmdWordPos: idx - len(cmd.AssignmentWords), CmdWord: word, CmdWordOffset: pos - word.Offset} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, Cmd: cmd, CmdWordPos: idx - len(cmd.AssignmentWords), CmdWord: word, CmdWordOffset: pos - word.Offset} } } var foundWord *WordType @@ -525,9 +533,10 @@ func (cmd *CmdType) findCompletionPos_simple(pos int) CompletionPos { endOffset := word.Offset + len(word.Raw) if pos <= startOffset { // starting a new word at this position - return CompletionPos{RawPos: pos, Cmd: cmd, CmdWordPos: idx} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, Cmd: cmd, CmdWordPos: idx} } - if pos == endOffset && word.uncompletable() { + if pos == endOffset && word.Type == WordTypeOp { + // operators are special, they can allow a full-word completion at endpos continue } if pos <= endOffset { @@ -539,12 +548,12 @@ func (cmd *CmdType) findCompletionPos_simple(pos int) CompletionPos { if foundWord != nil { if foundWord.uncompletable() { // invalid completion point - return CompletionPos{RawPos: pos, Cmd: cmd, CmdWordPos: foundWordIdx, CmdWord: foundWord, CmdWordOffset: pos - foundWord.Offset, CompInvalid: true} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, Cmd: cmd, CmdWordPos: foundWordIdx, CmdWord: foundWord, CmdWordOffset: pos - foundWord.Offset, CompInvalid: true} } - return CompletionPos{RawPos: pos, Cmd: cmd, CmdWordPos: foundWordIdx, CmdWord: foundWord, CmdWordOffset: pos - foundWord.Offset} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, Cmd: cmd, CmdWordPos: foundWordIdx, CmdWord: foundWord, CmdWordOffset: pos - foundWord.Offset} } // past the end, so we're starting a new word in Cmd - return CompletionPos{RawPos: pos, Cmd: cmd, CmdWordPos: len(cmd.Words)} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, Cmd: cmd, CmdWordPos: len(cmd.Words)} } func (cmd *CmdType) findWordAtPos_none(pos int) *WordType { @@ -558,12 +567,9 @@ func (cmd *CmdType) findWordAtPos_none(pos int) *WordType { return nil } if pos <= endOffset { - if word.uncompletable() { - // only return an uncompletable word if we are really in the middle of it - if pos == endOffset { - continue - } - return word + if pos == endOffset && word.Type == WordTypeOp { + // operators are special, they can allow a full-word completion at endpos + continue } return word } @@ -571,14 +577,61 @@ func (cmd *CmdType) findWordAtPos_none(pos int) *WordType { return nil } +func findWordAtPos(words []*WordType, pos int) *WordType { + for _, word := range words { + if pos > word.Offset && pos < word.Offset+len(word.Raw) { + return word + } + } + return nil +} + +// recursively descend down the word, parse commands and find a sub completion point if any. +// return nil if there is no sub completion point in this word +func findCompletionPosInWord(word *WordType, pos int, superOffset int) *CompletionPos { + if word.Type == WordTypeGroup || word.Type == WordTypeDQ || word.Type == WordTypeDDQ { + // need to descend further + wmeta := wordMetaMap[word.Type] + if pos <= wmeta.PrefixLen { + return nil + } + endPos := len(word.Raw) + if word.Complete { + endPos = endPos - wmeta.SuffixLen + } + if pos >= endPos { + return nil + } + subWord := findWordAtPos(word.Subs, pos-wmeta.PrefixLen) + if subWord == nil { + return nil + } + fullOffset := subWord.Offset + wmeta.PrefixLen + return findCompletionPosInWord(subWord, pos-fullOffset, superOffset+fullOffset) + } + if word.Type == WordTypeDP || word.Type == WordTypeBQ { + wmeta := wordMetaMap[word.Type] + if pos < wmeta.PrefixLen { + return nil + } + if word.Complete && pos > len(word.Raw)-wmeta.SuffixLen { + return nil + } + subCmds := ParseCommands(word.Subs) + newPos := FindCompletionPos(subCmds, pos-wmeta.PrefixLen, superOffset+wmeta.PrefixLen) + return &newPos + } + return nil +} + // returns the context for completion // if we are completing in a simple-command, the returns the Cmd. the Cmd can be used for specialized completion (command name, arg position, etc.) // if we are completing in a word, returns the Word. Word might be a group-word or DQ word, so it may need additional resolution (done in extend) // otherwise we are going to create a new word to insert at offset (so the context does not matter) -func FindCompletionPos(cmds []*CmdType, pos int) CompletionPos { +func findCompletionPosCmds(cmds []*CmdType, pos int, superOffset int) CompletionPos { if len(cmds) == 0 { // set CompCommand because we're starting a new command - return CompletionPos{RawPos: pos, CompCommand: true} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, CompCommand: true} } for _, cmd := range cmds { endOffset := cmd.endOffset() @@ -588,45 +641,58 @@ func FindCompletionPos(cmds []*CmdType, pos int) CompletionPos { startOffset := cmd.offset() if cmd.Type == CmdTypeSimple { if pos <= startOffset { - return CompletionPos{RawPos: pos, CompCommand: true} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, CompCommand: true} } - return cmd.findCompletionPos_simple(pos) + return cmd.findCompletionPos_simple(pos, superOffset) } else { // not in a simple-command // if we're before the none-command, just start a new command if pos <= startOffset { - return CompletionPos{RawPos: pos, CompCommand: true} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, CompCommand: true} } word := cmd.findWordAtPos_none(pos) if word == nil { // just revert to a file completion - return CompletionPos{RawPos: pos, CompCommand: false} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, CompCommand: false} } if word.uncompletable() { // ok, we're inside of a word in CmdTypeNone. if we're in an uncompletable word, return CompInvalid - return CompletionPos{RawPos: pos, CompInvalid: true} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, CmdWord: word, CmdWordOffset: pos - word.Offset, CompInvalid: true} } // revert to file completion - return CompletionPos{RawPos: pos, CmdWord: word, CmdWordOffset: pos - word.Offset} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, CmdWord: word, CmdWordOffset: pos - word.Offset} } } // past the end lastCmd := cmds[len(cmds)-1] if lastCmd.Type == CmdTypeSimple { // just extend last command - return CompletionPos{RawPos: pos, Cmd: lastCmd, CmdWordPos: len(lastCmd.Words)} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, Cmd: lastCmd, CmdWordPos: len(lastCmd.Words)} } // use lastCmd.NoneComplete to see if last command ended on a "separator". use that to set CompCommand - return CompletionPos{RawPos: pos, CompCommand: lastCmd.NoneComplete} + return CompletionPos{RawPos: pos, SuperOffset: superOffset, CompCommand: lastCmd.NoneComplete} } -func ResetWordOffsets(words []*WordType) { - pos := 0 +func FindCompletionPos(cmds []*CmdType, pos int, superOffset int) CompletionPos { + cpos := findCompletionPosCmds(cmds, pos, superOffset) + if cpos.CmdWord == nil { + return cpos + } + subPos := findCompletionPosInWord(cpos.CmdWord, cpos.CmdWordOffset, superOffset+cpos.CmdWord.Offset) + if subPos == nil { + return cpos + } else { + return *subPos + } +} + +func ResetWordOffsets(words []*WordType, startIdx int) { + pos := startIdx for _, word := range words { pos += len(word.Prefix) word.Offset = pos if len(word.Subs) > 0 { - ResetWordOffsets(word.Subs) + ResetWordOffsets(word.Subs, 0) } pos += len(word.Raw) } @@ -644,13 +710,23 @@ func (c *CmdType) stripPrefix() []rune { if len(c.AssignmentWords) > 0 { w := c.AssignmentWords[0] prefix := w.Prefix - w.Prefix = nil + if len(prefix) == 0 { + return nil + } + newWord := *w + newWord.Prefix = nil + c.AssignmentWords[0] = &newWord return prefix } if len(c.Words) > 0 { w := c.Words[0] prefix := w.Prefix - w.Prefix = nil + if len(prefix) == 0 { + return nil + } + newWord := *w + newWord.Prefix = nil + c.Words[0] = &newWord return prefix } return nil @@ -727,7 +803,7 @@ func cmdWhitespaceFixup(cmds []*CmdType) { nextCmd := cmds[idx+1] nextPrefix := nextCmd.stripPrefix() if len(nextPrefix) > 0 { - blankWord := &WordType{Type: WordTypeLit, QC: cmd.lastWord().QC, Offset: cmd.endOffset(), Prefix: nextPrefix, Complete: true} + blankWord := &WordType{Type: WordTypeLit, QC: cmd.lastWord().QC, Offset: cmd.endOffset() + len(nextPrefix), Prefix: nextPrefix, Complete: true} cmd.Words = append(cmd.Words, blankWord) } } diff --git a/pkg/shparse/shparse_test.go b/pkg/shparse/shparse_test.go index 11f9a6cd..a445dd45 100644 --- a/pkg/shparse/shparse_test.go +++ b/pkg/shparse/shparse_test.go @@ -3,6 +3,8 @@ package shparse import ( "fmt" "testing" + + "github.com/scripthaus-dev/sh2-server/pkg/utilfn" ) // $(ls f[*]); ./x @@ -102,3 +104,56 @@ func TestCmd(t *testing.T) { testParseCommands(t, `ls f"oo" "${x:"hello$y"}"`) testParseCommands(t, `x="foo $y" z=10 ls`) } + +func testCompPos(t *testing.T, cmdStr string, hasCommand bool, cmdWordPos int, hasWord bool, compInvalid bool, compCommand bool) { + cmdSP := utilfn.ParseToSP(cmdStr) + words := Tokenize(cmdSP.Str) + cmds := ParseCommands(words) + cpos := FindCompletionPos(cmds, cmdSP.Pos, 0) + fmt.Printf("testCompPos [%d] %q => %v\n", cmdSP.Pos, cmdStr, cpos) + if cpos.CmdWord != nil { + fmt.Printf(" found-word: %d %s\n", cpos.CmdWordOffset, cpos.CmdWord.stringWithPos(cpos.CmdWordOffset)) + } + if cpos.Cmd != nil { + fmt.Printf(" found-cmd: ") + dumpCommands([]*CmdType{cpos.Cmd}, " ", cpos.RawPos) + } + dumpCommands(cmds, " ", cmdSP.Pos) + fmt.Printf("\n") + if cpos.RawPos+cpos.SuperOffset != cmdSP.Pos { + t.Errorf("testCompPos %q => bad rawpos:%d superoffset:%d expected:%d", cmdStr, cpos.RawPos, cpos.SuperOffset, cmdSP.Pos) + } + if (cpos.Cmd != nil) != hasCommand { + t.Errorf("testCompPos %q => bad has-command exp:%v", cmdStr, hasCommand) + } + if (cpos.CmdWord != nil) != hasWord { + t.Errorf("testCompPos %q => bad has-word exp:%v", cmdStr, hasWord) + } + if cpos.CmdWordPos != cmdWordPos { + t.Errorf("testCompPos %q => bad cmd-word-pos got:%d exp:%d", cmdStr, cpos.CmdWordPos, cmdWordPos) + } + if cpos.CompInvalid != compInvalid { + t.Errorf("testCompPos %q => bad comp-invalid exp:%v", cmdStr, compInvalid) + } + if cpos.CompCommand != compCommand { + t.Errorf("testCompPos %q => bad comp-command exp:%v", cmdStr, compCommand) + } +} + +func TestCompPos(t *testing.T) { + testCompPos(t, "ls [*]foo", true, 1, false, false, false) + testCompPos(t, "ls foo [*];", true, 2, false, false, false) + testCompPos(t, "ls foo ;[*]", false, 0, false, false, true) + testCompPos(t, "ls foo >[*]> ./bar", true, 2, true, true, false) + testCompPos(t, "l[*]s", true, 0, true, false, false) + testCompPos(t, "ls[*]", true, 0, true, false, false) + testCompPos(t, "x=10 { (ls ./f[*] more); ls }", true, 1, true, false, false) + testCompPos(t, "for x in 1[*] 2 3; do ", false, 0, true, false, false) + testCompPos(t, "for[*] x in 1 2 3;", false, 0, true, true, false) + + testCompPos(t, "ls \"abc $(ls -l t[*])\" && foo", true, 2, true, false, false) + + testCompPos(t, "ls ${abc:$(ls -l [*])}", true, 1, true, false, false) + + testCompPos(t, `ls abc"$(ls $"echo $(ls ./[*]x) foo)" `, true, 1, true, false, false) +} diff --git a/pkg/utilfn/utilfn.go b/pkg/utilfn/utilfn.go index 2dace7fe..e3f392a0 100644 --- a/pkg/utilfn/utilfn.go +++ b/pkg/utilfn/utilfn.go @@ -128,6 +128,14 @@ func (sp StrWithPos) String() string { return strWithCursor(sp.Str, sp.Pos) } +func ParseToSP(s string) StrWithPos { + idx := strings.Index(s, "[*]") + if idx == -1 { + return StrWithPos{Str: s} + } + return StrWithPos{Str: s[0:idx] + s[idx+3:], Pos: idx} +} + func strWithCursor(str string, pos int) string { if pos < 0 { return "[*]_" + str