waveterm/wavesrv/pkg/shparse/comp.go
Mike Sawka 422338c04b
zsh support (#227)
adds zsh support to waveterm.  big change, lots going on here.  lots of other improvements and bug fixes added while debugging and building out the feature.

Commits:

* refactor shexec parser.go into new package shellenv.  separate out bash specific parsing from generic functions

* checkpoint

* work on refactoring shexec.  created two new packages shellapi (for bash/zsh specific stuff), and shellutil (shared between shellapi and shexec)

* more refactoring

* create shellapi interface to abstract bash specific functionality

* more refactoring, move bash shell state parsing to shellapi

* move makeRcFile to shellapi.  remove all of the 'client' options CLI options from waveshell

* get shellType passed through to server/single paths for waveshell

* add a local shelltype detector

* mock out a zshapi

* move shelltype through more of the code

* get a command to run via zsh

* zsh can now switch directories.  poc, needs cleanup

* working on ShellState encoding differences between zsh/bash.  Working on parsing zsh decls.  move utilfn package into waveshell (shouldn't have been in wavesrv)

* switch to use []byte for vardecl serialization + diffs

* progress on zsh environment.  still have issues reconciling init environment with trap environment

* fix typeset argument parsing

* parse promptvars, more zsh specific ignores

* fix bug with promptvar not getting set (wrong check in FeState func)

* add sdk (issue #188) to list of rtnstate commands

* more zsh compatibility -- working with a larger ohmyzsh environment.  ignore more variables, handle exit trap better.  unique path/fpath.  add a processtype variable to base.

* must return a value

* zsh alias parsing/restoring.  diff changes (and rtnstate changes).  introduces linediff v1.

* force zmodload of zsh/parameter

* starting work on zsh functions

* need a v1 of mapdiff as well (to handle null chars)

* pack/unpack of ints was wrong (one used int and one use uint).  turned out we only ever encoded '0' so it worked.  that also means it is safe to change unpack to unpackUInt

* reworking for binary encoding of aliases and functions (because of zsh allows any character, including nulls, in names and values)

* fixes, working on functions, issue with line endings

* zsh functions.  lots of ugliness here around dealing with line dicipline and cooked stty.  new runcommand function to grab output from a non-tty fd.  note that we still to run the actual command in a stty to get the proper output.

* write uuid tempdir, cleanup with tmprcfilename code

* hack in some simple zsh function declaration finding code for rtnstate.  create function diff for rtnstate that supports zsh

* make sure key order is constant so shell hashes are consistent

* fix problems with state diffs to support new zsh formats.  add diff/apply code to shellapi (moved from shellenv), that is now specific to zsh or bash

* add log packet and new shellstate packets

* switch to shellstate map that's also keyed by shelltype

* add shelltype to remoteinstance

* remove shell argument from waveshell

* added new shelltype statemap to remote.go (msh), deal with fallout

* move shellstate out of init packet, and move to an explicit reinit call.  try to initialize all of the active shell states

* change dont always store init state (only store on demand).  initialize shell states on demand (if not already initialized).  allow reset to change shells

* add shellpref field to remote table.  use to drive the default shell choice for new tabs

* show shelltag on cmdinput, pass through ri and remote (defaultshellstate)

* bump mshell version to v0.4

* better version validation for shellstate.  also relax compatibility requirements for diffing states (shelltype + major version need to match)

* better error handling, check shellstate compatibility during run (on waveshell server)

* add extra separator for bash shellstate processing to deal with spurious output from rc files

* special migration for v30 -- flag invalid bash shell states and show special button in UI to fix

* format

* remove zsh-decls (unused)

* remove test code

* remove debug print

* fix typo
2024-01-16 16:11:04 -08:00

292 lines
9.2 KiB
Go

// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package shparse
import (
"strings"
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
)
const (
CompTypeCommandMeta = "command-meta"
CompTypeCommand = "command"
CompTypeArg = "command-arg"
CompTypeInvalid = "invalid"
CompTypeVar = "var"
CompTypeAssignment = "assignment"
CompTypeBasic = "basic"
)
type CompletionPos struct {
RawPos int // the raw position of cursor
SuperOffset int // adjust all offsets in Cmd and CmdWord by SuperOffset
CompType string // see CompType* constants
Cmd *CmdType // nil if between commands or a special completion (otherwise will be a SimpleCommand)
// index into cmd.Words (only set when Cmd is not nil, otherwise we look at CompCommand)
// 0 means command-word
// negative means assignment-words.
// can be past the end of Words (means start new word).
CmdWordPos int
CompWord *WordType // set to the word we are completing (nil if we are starting a new word)
CompWordOffset int // offset into compword (only if CmdWord is not nil)
}
func compTypeFromPos(cmdWordPos int) string {
if cmdWordPos == 0 {
return CompTypeCommand
}
if cmdWordPos < 0 {
return CompTypeAssignment
}
return CompTypeArg
}
func (cmd *CmdType) findCompletionPos_simple(pos int, superOffset int) CompletionPos {
if cmd.Type != CmdTypeSimple {
panic("findCompletetionPos_simple only works for CmdTypeSimple")
}
rtn := CompletionPos{RawPos: pos, SuperOffset: superOffset, Cmd: cmd}
for idx, word := range cmd.AssignmentWords {
startOffset := word.Offset
endOffset := word.Offset + len(word.Raw)
if pos <= startOffset {
// starting a new word at this position (before the current assignment word)
rtn.CmdWordPos = idx - len(cmd.AssignmentWords)
rtn.CompType = CompTypeAssignment
return rtn
}
if pos <= endOffset {
// completing an assignment word
rtn.CmdWordPos = idx - len(cmd.AssignmentWords)
rtn.CompWord = word
rtn.CompWordOffset = pos - word.Offset
rtn.CompType = CompTypeAssignment
return rtn
}
}
var foundWord *WordType
var foundWordIdx int
for idx, word := range cmd.Words {
startOffset := word.Offset
endOffset := word.Offset + len(word.Raw)
if pos <= startOffset {
// starting a new word at this position
rtn.CmdWordPos = idx
rtn.CompType = compTypeFromPos(idx)
return rtn
}
if pos == endOffset && word.Type == WordTypeOp {
// operators are special, they can allow a full-word completion at endpos
continue
}
if pos <= endOffset {
foundWord = word
foundWordIdx = idx
break
}
}
if foundWord != nil {
rtn.CmdWordPos = foundWordIdx
rtn.CompWord = foundWord
rtn.CompWordOffset = pos - foundWord.Offset
if foundWord.uncompletable() {
// invalid completion point
rtn.CompType = CompTypeInvalid
return rtn
}
rtn.CompType = compTypeFromPos(foundWordIdx)
return rtn
}
// past the end, so we're starting a new word in Cmd
rtn.CmdWordPos = len(cmd.Words)
rtn.CompType = CompTypeArg
return rtn
}
func (cmd *CmdType) findCompletionPos_none(pos int, superOffset int) CompletionPos {
rtn := CompletionPos{RawPos: pos, SuperOffset: superOffset}
if cmd.Type != CmdTypeNone {
panic("findCompletionPos_none only works for CmdTypeNone")
}
var foundWord *WordType
for _, word := range cmd.Words {
startOffset := word.Offset
endOffset := word.Offset + len(word.Raw)
if pos <= startOffset {
break
}
if pos <= endOffset {
if pos == endOffset && word.Type == WordTypeOp {
// operators are special, they can allow a full-word completion at endpos
continue
}
foundWord = word
break
}
}
if foundWord == nil {
// just revert to a file completion
rtn.CompType = CompTypeBasic
return rtn
}
foundWordOffset := pos - foundWord.Offset
rtn.CompWord = foundWord
rtn.CompWordOffset = foundWordOffset
if foundWord.uncompletable() {
// ok, we're inside of a word in CmdTypeNone. if we're in an uncompletable word, return CompInvalid
rtn.CompType = CompTypeInvalid
return rtn
}
if foundWordOffset > 0 && foundWordOffset < foundWord.contentStartPos() {
// cursor is in a weird position, between characters of a multi-char prefix (e.g. "$[*]{hello}" or $[*]'hello'). cannot complete.
rtn.CompType = CompTypeInvalid
return rtn
}
// revert to file completion
rtn.CompType = CompTypeBasic
return rtn
}
func findCompletionWordAtPos(words []*WordType, pos int, allowEndMatch bool) *WordType {
// WordTypeSimpleVar is special (always allowEndMatch), if cursor is at the end of SimpleVar it is returned
for _, word := range words {
if pos > word.Offset && pos < word.Offset+len(word.Raw) {
return word
}
if (allowEndMatch || word.Type == WordTypeSimpleVar) && 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, posInWord int, superOffset int) *CompletionPos {
rawPos := word.Offset + posInWord
if word.Type == WordTypeGroup || word.Type == WordTypeDQ || word.Type == WordTypeDDQ {
// need to descend further
if posInWord <= word.contentStartPos() {
return nil
}
if posInWord > word.contentEndPos() {
return nil
}
subWord := findCompletionWordAtPos(word.Subs, posInWord-word.contentStartPos(), false)
if subWord == nil {
return nil
}
return findCompletionPosInWord(subWord, posInWord-(subWord.Offset+word.contentStartPos()), superOffset+(word.Offset+word.contentStartPos()))
}
if word.Type == WordTypeDP || word.Type == WordTypeBQ {
if posInWord < word.contentStartPos() {
return nil
}
if posInWord > word.contentEndPos() {
return nil
}
subCmds := ParseCommands(word.Subs)
newPos := findCompletionPosInternal(subCmds, posInWord-word.contentStartPos(), superOffset+(word.Offset+word.contentStartPos()))
return &newPos
}
if word.Type == WordTypeSimpleVar || word.Type == WordTypeVarBrace {
// special "var" completion
rtn := &CompletionPos{RawPos: rawPos, SuperOffset: superOffset}
rtn.CompType = CompTypeVar
rtn.CompWordOffset = posInWord
rtn.CompWord = word
return rtn
}
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 findCompletionPosCmds(cmds []*CmdType, pos int, superOffset int) CompletionPos {
rtn := CompletionPos{RawPos: pos, SuperOffset: superOffset}
if len(cmds) == 0 {
// set CompCommand because we're starting a new command
rtn.CompType = CompTypeCommand
return rtn
}
for _, cmd := range cmds {
endOffset := cmd.endOffset()
if pos > endOffset || (cmd.Type == CmdTypeNone && pos == endOffset) {
continue
}
startOffset := cmd.offset()
if cmd.Type == CmdTypeSimple {
if pos <= startOffset {
rtn.CompType = CompTypeCommand
return rtn
}
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 {
rtn.CompType = CompTypeCommand
return rtn
}
return cmd.findCompletionPos_none(pos, superOffset)
}
}
// past the end
lastCmd := cmds[len(cmds)-1]
if lastCmd.Type == CmdTypeSimple {
// just extend last command
rtn.Cmd = lastCmd
rtn.CmdWordPos = len(lastCmd.Words)
rtn.CompType = CompTypeArg
return rtn
}
// use lastCmd.NoneComplete to see if last command ended on a "separator". use that to set CompCommand
if lastCmd.NoneComplete {
rtn.CompType = CompTypeCommand
} else {
rtn.CompType = CompTypeBasic
}
return rtn
}
func findCompletionPosInternal(cmds []*CmdType, pos int, superOffset int) CompletionPos {
cpos := findCompletionPosCmds(cmds, pos, superOffset)
if cpos.CompWord == nil {
return cpos
}
subPos := findCompletionPosInWord(cpos.CompWord, cpos.CompWordOffset, superOffset)
if subPos != nil {
return *subPos
}
return cpos
}
func FindCompletionPos(cmds []*CmdType, pos int) CompletionPos {
cpos := findCompletionPosInternal(cmds, pos, 0)
if cpos.CompType == CompTypeCommand && cpos.SuperOffset == 0 && cpos.CompWord != nil && cpos.CompWord.Offset == 0 && strings.HasPrefix(string(cpos.CompWord.Raw), "/") {
cpos.CompType = CompTypeCommandMeta
}
return cpos
}
func (cpos CompletionPos) Extend(origStr utilfn.StrWithPos, extensionStr string, extensionComplete bool) utilfn.StrWithPos {
compWord := cpos.CompWord
if compWord == nil {
compWord = MakeEmptyWord(WordTypeLit, nil, cpos.RawPos, true)
}
realOffset := compWord.Offset + cpos.SuperOffset
if strings.HasSuffix(extensionStr, "/") {
extensionComplete = false
}
rtnSP := Extend(compWord, cpos.CompWordOffset, extensionStr, extensionComplete)
origRunes := []rune(origStr.Str)
rtnSP = rtnSP.Prepend(string(origRunes[0:realOffset]))
rtnSP = rtnSP.Append(string(origRunes[realOffset+len(compWord.Raw):]))
return rtnSP
}