Skip to content

feat(autocomplete): handle positional arguments #769

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 78 additions & 10 deletions internal/core/autocomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@ type AutocompleteResponse struct {
Suggestions AutocompleteSuggestions
}

const variableFlagValueNodeID = "*"
const (
// positionalValueNodeID are flag values or positional argument.
// E.g.: `scw test create <value> --flag <value>`
positionalValueNodeID = "*"
)

type AutoCompleteNodeType int
type AutoCompleteNodeType uint

const (
AutoCompleteNodeTypeCommand = iota
AutoCompleteNodeTypeCommand AutoCompleteNodeType = iota
AutoCompleteNodeTypePositionalArgument
AutoCompleteNodeTypeArgument
AutoCompleteNodeTypeFlag
AutoCompleteNodeTypeFlagValueConst
Expand Down Expand Up @@ -98,6 +103,20 @@ func NewAutoCompleteCommandNode() *AutoCompleteNode {
}
}

// NewAutoCompletePositionalArgNode creates a new node corresponding to a command positional argument.
// These nodes are not necessarily leaf nodes.
func NewAutoCompletePositionalArgNode(parent *AutoCompleteNode, argSpec *ArgSpec) *AutoCompleteNode {
positionalArgumentNode := &AutoCompleteNode{
Children: make(map[string]*AutoCompleteNode),
ArgSpec: argSpec,
Type: AutoCompleteNodeTypePositionalArgument,
}

parent.Children[positionalValueNodeID] = positionalArgumentNode

return positionalArgumentNode
}

// NewArgAutoCompleteNode creates a new node corresponding to a command argument.
// These nodes are leaf nodes.
func NewAutoCompleteArgNode(argSpec *ArgSpec) *AutoCompleteNode {
Expand All @@ -120,7 +139,7 @@ func NewAutoCompleteFlagNode(parent *AutoCompleteNode, flagSpec *FlagSpec) *Auto
Name: flagSpec.Name,
}
if flagSpec.HasVariableValue {
node.Children[variableFlagValueNodeID] = &AutoCompleteNode{
node.Children[positionalValueNodeID] = &AutoCompleteNode{
Children: parent.Children,
Type: AutoCompleteNodeTypeFlagValueVariable,
}
Expand Down Expand Up @@ -164,21 +183,21 @@ func (node *AutoCompleteNode) GetChildMatch(name string) (*AutoCompleteNode, boo
return nil, false
}

// isLeafCommand returns true only if n is a command (namespace or verb or resource) but has no child command
// a leaf command can have 2 types of children: arguments or flags
// isLeafCommand returns true only if n is a node with no child command (namespace, verb, resource) or a positional arg.
// A leaf command can have 2 types of children: arguments or flags
func (node *AutoCompleteNode) isLeafCommand() bool {
if node.Type != AutoCompleteNodeTypeCommand {
if node.Type != AutoCompleteNodeTypeCommand && node.Type != AutoCompleteNodeTypePositionalArgument {
return false
}
for _, child := range node.Children {
if child.Type == AutoCompleteNodeTypeCommand {
if child.Type == AutoCompleteNodeTypeCommand || child.Type == AutoCompleteNodeTypePositionalArgument {
return false
}
}
return true
}

// BuildAutoCompleteTree builds the autocomplete tree from the commands, subcomands and arguments
// BuildAutoCompleteTree builds the autocomplete tree from the commands, subcommands and arguments
func BuildAutoCompleteTree(commands *Commands) *AutoCompleteNode {
root := NewAutoCompleteCommandNode()
scwCommand := root.GetChildOrCreate("scw")
Expand All @@ -195,8 +214,19 @@ func BuildAutoCompleteTree(commands *Commands) *AutoCompleteNode {
}

node.Command = cmd

// Create node for positional argument if the command has one.
positionalArg := cmd.ArgSpecs.GetPositionalArg()
if positionalArg != nil {
node = NewAutoCompletePositionalArgNode(node, positionalArg)
node.addGlobalFlags()
}

// We consider ArgSpecs as leaf in the autocomplete tree.
for _, argSpec := range cmd.ArgSpecs {
if argSpec == positionalArg {
continue
}
node.Children[argSpec.Name+"="] = NewAutoCompleteArgNode(argSpec)
}

Expand Down Expand Up @@ -231,7 +261,7 @@ func AutoComplete(ctx context.Context, leftWords []string, wordToComplete string
for i, word := range leftWords {
children, childrenExists := node.Children[word]
if !childrenExists {
children, childrenExists = node.Children[variableFlagValueNodeID]
children, childrenExists = node.Children[positionalValueNodeID]
}

switch {
Expand All @@ -248,6 +278,10 @@ func AutoComplete(ctx context.Context, leftWords []string, wordToComplete string
// Do nothing
// Arguments do not have children: they are not used to go deeper into the tree

case children.Type == AutoCompleteNodeTypePositionalArgument && isCompletingArgValue(word):
// Do nothing
// Setting a positional argument with `key=value` notation is not allowed.

default:
// word is a namespace or verb or resource or flag or flag value
node = children
Expand Down Expand Up @@ -288,6 +322,11 @@ func AutoComplete(ctx context.Context, leftWords []string, wordToComplete string
}
}

if isCompletingPositionalArgValue(node, wordToComplete) {
suggestions := AutoCompleteArgValue(ctx, node.Children[positionalValueNodeID].ArgSpec, wordToComplete)
return newAutoCompleteResponse(suggestions)
}

if isCompletingArgValue(wordToComplete) {
argName, argValuePrefix := splitArgWord(wordToComplete)
argNode, exist := node.GetChildMatch(argName)
Expand Down Expand Up @@ -359,6 +398,35 @@ func AutoCompleteArgValue(ctx context.Context, argSpec *ArgSpec, argValuePrefix
return suggestions
}

// isCompletingPositionalArgValue detects if the word to complete is a positional argument on a given node.
// Returns false on the following cases:
// - node has no positional argument
// - a flag is being completed
func isCompletingPositionalArgValue(node *AutoCompleteNode, wordToComplete string) bool {
// Return false if node has no value node children
valueNode, exist := node.Children[positionalValueNodeID]
if !exist {
return false
}

// return false if this value node children is not of type positional arg
if valueNode.Type != AutoCompleteNodeTypePositionalArgument {
return false
}

// Catch when a flag is being completed.
for child := range node.Children {
if child == positionalValueNodeID || wordToComplete == "" {
continue
}
if strings.HasPrefix(child, wordToComplete) {
return false
}
}

return true
}

func isCompletingArgValue(wordToComplete string) bool {
wordParts := strings.SplitN(wordToComplete, "=", 2)
return len(wordParts) == 2
Expand Down
26 changes: 22 additions & 4 deletions internal/core/autocomplete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ func testAutocompleteGetCommands() *Commands {
Verb: "delete",
ArgSpecs: ArgSpecs{
{
Name: "flower",
Name: "name",
EnumValues: []string{"hibiscus", "anemone"},
Positional: true,
},
{
Name: "with-leaves",
},
},
},
Expand Down Expand Up @@ -125,8 +130,13 @@ func TestAutocomplete(t *testing.T) {
t.Run("scw test flower create leaves.0.", run(&testCase{Suggestions: AutocompleteSuggestions{"leaves.0.size="}}))
t.Run("scw test flower create leaves.0.size=M leaves", run(&testCase{Suggestions: AutocompleteSuggestions{"leaves.1.size="}}))
t.Run("scw test flower create leaves.0.size=M leaves leaves.1.size=M", run(&testCase{WordToCompleteIndex: 5, Suggestions: AutocompleteSuggestions{"leaves.2.size="}}))
t.Run("scw test flower delete f", run(&testCase{Suggestions: AutocompleteSuggestions{"flower="}}))
t.Run("scw test flower delete flower f", run(&testCase{Suggestions: nil}))
t.Run("scw test flower delete ", run(&testCase{Suggestions: AutocompleteSuggestions{"anemone", "hibiscus"}}))
t.Run("scw test flower delete w", run(&testCase{Suggestions: nil}))
t.Run("scw test flower delete h", run(&testCase{Suggestions: AutocompleteSuggestions{"hibiscus"}}))
t.Run("scw test flower delete with-leaves=true ", run(&testCase{Suggestions: AutocompleteSuggestions{"anemone", "hibiscus"}})) // invalid notation
t.Run("scw test flower delete hibiscus n", run(&testCase{Suggestions: nil}))
t.Run("scw test flower delete hibiscus w", run(&testCase{Suggestions: AutocompleteSuggestions{"with-leaves="}}))
t.Run("scw test flower delete hibiscus with-leaves=true", run(&testCase{Suggestions: nil}))
// TODO: t.Run("scw test flower create leaves.0.size=", run(&testCase{Suggestions: AutocompleteSuggestions{"L", "M", "S", "XL", "XXL"}}))

t.Run("scw -", run(&testCase{Suggestions: AutocompleteSuggestions{"--debug", "--help", "--output", "--profile", "-D", "-h", "-o", "-p"}}))
Expand All @@ -136,10 +146,18 @@ func TestAutocomplete(t *testing.T) {
t.Run("scw test flower create name=p -o j", run(&testCase{Suggestions: AutocompleteSuggestions{"json"}}))
t.Run("scw test flower create name=p -o json ", run(&testCase{Suggestions: AutocompleteSuggestions{"colours.0=", "leaves.0.size=", "size=", "species="}}))
t.Run("scw test flower create name=p -o=json ", run(&testCase{Suggestions: AutocompleteSuggestions{"colours.0=", "leaves.0.size=", "size=", "species="}}))
t.Run("scw test flower create name=p -o=jso", run(&testCase{Suggestions: nil}))
t.Run("scw test flower create name=p -o=jso", run(&testCase{Suggestions: nil})) // TODO: make this work
t.Run("scw test flower create name=p -o", run(&testCase{Suggestions: AutocompleteSuggestions{"-o"}}))
t.Run("scw test -o json flower create ", run(&testCase{Suggestions: AutocompleteSuggestions{"colours.0=", "leaves.0.size=", "name=", "size=", "species="}}))
t.Run("scw test flower create name=p --profile xxxx ", run(&testCase{Suggestions: AutocompleteSuggestions{"colours.0=", "leaves.0.size=", "size=", "species="}}))
t.Run("scw test --profile xxxx flower create name=p ", run(&testCase{Suggestions: AutocompleteSuggestions{"colours.0=", "leaves.0.size=", "size=", "species="}}))
t.Run("scw test flower create name=p --profile xxxx", run(&testCase{Suggestions: nil}))

t.Run("scw test flower -o json delete -", run(&testCase{Suggestions: AutocompleteSuggestions{"--debug", "--help", "--output", "--profile", "-D", "-h", "-p"}}))
t.Run("scw test flower delete -o ", run(&testCase{Suggestions: AutocompleteSuggestions{"human", "json"}}))
t.Run("scw test flower delete -o j", run(&testCase{Suggestions: AutocompleteSuggestions{"json"}}))
t.Run("scw test flower delete -o json ", run(&testCase{Suggestions: AutocompleteSuggestions{"anemone", "hibiscus"}}))
t.Run("scw test flower delete -o=json ", run(&testCase{Suggestions: AutocompleteSuggestions{"anemone", "hibiscus"}}))
t.Run("scw test flower delete -o json hibiscus w", run(&testCase{Suggestions: AutocompleteSuggestions{"with-leaves="}}))
t.Run("scw test flower delete -o=json hibiscus w", run(&testCase{Suggestions: AutocompleteSuggestions{"with-leaves="}}))
}