Skip to content

Commit 1387dd5

Browse files
Codelaxremyleone
andauthored
feat(core/autocomplete): complete content of args using list verbs (#2708)
Co-authored-by: Rémy Léone <[email protected]>
1 parent c78eae6 commit 1387dd5

File tree

7 files changed

+236
-31
lines changed

7 files changed

+236
-31
lines changed

README.md

+7
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ scw instance server list
105105
scw k8s cluster create name=foo version=1.17.4 pools.0.size=3 pools.0.node-type=DEV1-M pools.0.name=default tags.0=tag1 tags.1=tag2
106106
```
107107

108+
## Environment
109+
110+
You can configure your config or enable functionalities with environment variables.
111+
112+
Variables to override config are describe in [config documentation](docs/commands/config.md).
113+
To enable beta features, you can set `SCW_ENABLE_BETA=1` in your environment.
114+
108115
# Reference documentation
109116

110117
| Namespace | Description | Documentation |

cmd/scw/main.go

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ func main() {
7474
Stdout: colorable.NewColorableStdout(),
7575
Stderr: colorable.NewColorableStderr(),
7676
Stdin: os.Stdin,
77+
BetaMode: BetaMode,
7778
})
7879

7980
os.Exit(exitCode)

internal/core/autocomplete.go

+21-7
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ func AutoComplete(ctx context.Context, leftWords []string, wordToComplete string
291291
}
292292

293293
// keep track of the completed args
294-
completedArgs := make(map[string]struct{})
294+
completedArgs := make(map[string]string)
295295

296296
// keep track of the completed flags
297297
completedFlags := make(map[string]struct{})
@@ -307,12 +307,12 @@ func AutoComplete(ctx context.Context, leftWords []string, wordToComplete string
307307

308308
// handle arg=value
309309
case isArg(word):
310-
completedArgs[wordKey(word)+"="] = struct{}{}
310+
completedArgs[wordKey(word)+"="] = wordValue(word)
311311

312312
// handle boolean arg
313313
default:
314314
if _, exist := node.Children[positionalValueNodeID]; exist && i > nodeIndexInWords {
315-
completedArgs[word] = struct{}{}
315+
completedArgs[word] = ""
316316
}
317317
}
318318
}
@@ -324,7 +324,7 @@ func AutoComplete(ctx context.Context, leftWords []string, wordToComplete string
324324
// We try to complete the value of an unknown arg
325325
return &AutocompleteResponse{}
326326
}
327-
suggestions := AutoCompleteArgValue(ctx, argNode.Command, argNode.ArgSpec, argValuePrefix)
327+
suggestions := AutoCompleteArgValue(ctx, argNode.Command, argNode.ArgSpec, argValuePrefix, completedArgs)
328328

329329
// We need to prefix suggestions with the argName to enable the arg value auto-completion.
330330
for k, s := range suggestions {
@@ -338,7 +338,7 @@ func AutoComplete(ctx context.Context, leftWords []string, wordToComplete string
338338
suggestions := []string(nil)
339339
for key, child := range node.Children {
340340
if key == positionalValueNodeID {
341-
for _, positionalSuggestion := range AutoCompleteArgValue(ctx, child.Command, child.ArgSpec, wordToComplete) {
341+
for _, positionalSuggestion := range AutoCompleteArgValue(ctx, child.Command, child.ArgSpec, wordToComplete, completedArgs) {
342342
if _, exists := completedArgs[positionalSuggestion]; !exists {
343343
suggestions = append(suggestions, positionalSuggestion)
344344
}
@@ -380,7 +380,7 @@ func AutoComplete(ctx context.Context, leftWords []string, wordToComplete string
380380
// AutoCompleteArgValue returns suggestions for a (argument name, argument value prefix) pair.
381381
// Priority is given to the AutoCompleteFunc from the ArgSpec, if it is set.
382382
// Otherwise, we use EnumValues from the ArgSpec.
383-
func AutoCompleteArgValue(ctx context.Context, cmd *Command, argSpec *ArgSpec, argValuePrefix string) []string {
383+
func AutoCompleteArgValue(ctx context.Context, cmd *Command, argSpec *ArgSpec, argValuePrefix string, completedArgs map[string]string) []string {
384384
if argSpec == nil {
385385
return nil
386386
}
@@ -401,6 +401,12 @@ func AutoCompleteArgValue(ctx context.Context, cmd *Command, argSpec *ArgSpec, a
401401
possibleValues = argSpec.EnumValues
402402
}
403403

404+
// Complete arg value using list verb if possible
405+
// "instance server get <tab>" completes "server-id" arg with "id" in instance server list
406+
if len(possibleValues) == 0 && ExtractBetaMode(ctx) {
407+
possibleValues = AutocompleteGetArg(ctx, cmd, argSpec, completedArgs)
408+
}
409+
404410
suggestions := []string(nil)
405411
for _, value := range possibleValues {
406412
if strings.HasPrefix(value, argValuePrefix) {
@@ -424,6 +430,14 @@ func wordKey(word string) string {
424430
return strings.SplitN(word, "=", 2)[0]
425431
}
426432

433+
func wordValue(word string) string {
434+
words := strings.SplitN(word, "=", 2)
435+
if len(words) >= 2 {
436+
return words[1]
437+
}
438+
return ""
439+
}
440+
427441
func isArg(wordToComplete string) bool {
428442
return strings.Contains(wordToComplete, "=")
429443
}
@@ -460,7 +474,7 @@ func hasPrefix(key, wordToComplete string) bool {
460474

461475
// keySuggestion will suggest the next key available for the map (or array) argument.
462476
// Keys are suggested in ascending order arg.0, arg.1, arg.2...
463-
func keySuggestion(key string, completedArg map[string]struct{}, wordToComplete string) []string {
477+
func keySuggestion(key string, completedArg map[string]string, wordToComplete string) []string {
464478
splitKey := strings.Split(key, ".")
465479
splitWordToComplete := strings.Split(wordToComplete, ".")
466480

internal/core/autocomplete_test.go

+107-24
Original file line numberDiff line numberDiff line change
@@ -66,37 +66,43 @@ func testAutocompleteGetCommands() *Commands {
6666
)
6767
}
6868

69+
type autoCompleteTestCase struct {
70+
Suggestions AutocompleteSuggestions
71+
WordToCompleteIndex int
72+
Words []string
73+
}
74+
75+
func runAutocompleteTest(ctx context.Context, tc *autoCompleteTestCase) func(*testing.T) {
76+
return func(t *testing.T) {
77+
words := tc.Words
78+
if len(words) == 0 {
79+
name := strings.Replace(t.Name(), "TestAutocomplete/", "", -1)
80+
name = strings.Replace(name, "_", " ", -1)
81+
words = strings.Split(name, " ")
82+
}
83+
84+
wordToCompleteIndex := len(words) - 1
85+
if tc.WordToCompleteIndex != 0 {
86+
wordToCompleteIndex = tc.WordToCompleteIndex
87+
}
88+
leftWords := words[:wordToCompleteIndex]
89+
wordToComplete := words[wordToCompleteIndex]
90+
rightWord := words[wordToCompleteIndex+1:]
91+
92+
result := AutoComplete(ctx, leftWords, wordToComplete, rightWord)
93+
assert.Equal(t, tc.Suggestions, result.Suggestions)
94+
}
95+
}
96+
6997
func TestAutocomplete(t *testing.T) {
7098
ctx := injectMeta(context.Background(), &meta{
7199
Commands: testAutocompleteGetCommands(),
72100
})
73101

74-
type testCase struct {
75-
Suggestions AutocompleteSuggestions
76-
WordToCompleteIndex int
77-
Words []string
78-
}
102+
type testCase = autoCompleteTestCase
79103

80104
run := func(tc *testCase) func(*testing.T) {
81-
return func(t *testing.T) {
82-
words := tc.Words
83-
if len(words) == 0 {
84-
name := strings.Replace(t.Name(), "TestAutocomplete/", "", -1)
85-
name = strings.Replace(name, "_", " ", -1)
86-
words = strings.Split(name, " ")
87-
}
88-
89-
wordToCompleteIndex := len(words) - 1
90-
if tc.WordToCompleteIndex != 0 {
91-
wordToCompleteIndex = tc.WordToCompleteIndex
92-
}
93-
leftWords := words[:wordToCompleteIndex]
94-
wordToComplete := words[wordToCompleteIndex]
95-
rightWord := words[wordToCompleteIndex+1:]
96-
97-
result := AutoComplete(ctx, leftWords, wordToComplete, rightWord)
98-
assert.Equal(t, tc.Suggestions, result.Suggestions)
99-
}
105+
return runAutocompleteTest(ctx, tc)
100106
}
101107

102108
t.Run("scw ", run(&testCase{Suggestions: AutocompleteSuggestions{"test"}}))
@@ -169,3 +175,80 @@ func TestAutocomplete(t *testing.T) {
169175
t.Run("scw test flower delete -o json hibiscus w", run(&testCase{Suggestions: AutocompleteSuggestions{"with-leaves="}}))
170176
t.Run("scw test flower delete -o=json hibiscus w", run(&testCase{Suggestions: AutocompleteSuggestions{"with-leaves="}}))
171177
}
178+
179+
func TestAutocompleteArgs(t *testing.T) {
180+
commands := testAutocompleteGetCommands()
181+
commands.Add(&Command{
182+
Namespace: "test",
183+
Resource: "flower",
184+
Verb: "get",
185+
ArgsType: reflect.TypeOf(struct {
186+
Name string
187+
MaterialName string
188+
}{}),
189+
ArgSpecs: ArgSpecs{
190+
{
191+
Name: "name",
192+
Positional: true,
193+
},
194+
{
195+
Name: "material-name",
196+
},
197+
},
198+
})
199+
commands.Add(&Command{
200+
Namespace: "test",
201+
Resource: "flower",
202+
Verb: "list",
203+
ArgsType: reflect.TypeOf(struct {
204+
}{}),
205+
ArgSpecs: ArgSpecs{},
206+
Run: func(ctx context.Context, argsI interface{}) (interface{}, error) {
207+
return []*struct {
208+
Name string
209+
}{
210+
{
211+
Name: "flower1",
212+
},
213+
{
214+
Name: "flower2",
215+
},
216+
}, nil
217+
},
218+
})
219+
commands.Add(&Command{
220+
Namespace: "test",
221+
Resource: "material",
222+
Verb: "list",
223+
ArgsType: reflect.TypeOf(struct {
224+
}{}),
225+
ArgSpecs: ArgSpecs{},
226+
Run: func(ctx context.Context, argsI interface{}) (interface{}, error) {
227+
return []*struct {
228+
Name string
229+
}{
230+
{
231+
Name: "material1",
232+
},
233+
{
234+
Name: "material2",
235+
},
236+
}, nil
237+
},
238+
})
239+
ctx := injectMeta(context.Background(), &meta{
240+
Commands: commands,
241+
betaMode: true,
242+
})
243+
244+
type testCase = autoCompleteTestCase
245+
246+
run := func(tc *testCase) func(*testing.T) {
247+
return runAutocompleteTest(ctx, tc)
248+
}
249+
250+
t.Run("scw test flower get ", run(&testCase{Suggestions: AutocompleteSuggestions{"flower1", "flower2", "material-name="}}))
251+
t.Run("scw test flower get material-name=", run(&testCase{Suggestions: AutocompleteSuggestions{"material-name=material1", "material-name=material2"}}))
252+
t.Run("scw test flower get material-name=mat ", run(&testCase{Suggestions: AutocompleteSuggestions{"flower1", "flower2"}}))
253+
t.Run("scw test flower create name=", run(&testCase{Suggestions: AutocompleteSuggestions(nil)}))
254+
}

internal/core/autocomplete_utils.go

+91
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package core
22

33
import (
44
"context"
5+
"reflect"
56
"strings"
67

8+
"github.com/scaleway/scaleway-cli/v2/internal/args"
79
"github.com/scaleway/scaleway-sdk-go/scw"
10+
"github.com/scaleway/scaleway-sdk-go/strcase"
811
)
912

1013
func AutocompleteProfileName() AutoCompleteArgFunc {
@@ -28,3 +31,91 @@ func AutocompleteProfileName() AutoCompleteArgFunc {
2831
return res
2932
}
3033
}
34+
35+
// AutocompleteGetArg tries to complete an argument by using the list verb if it exists for the same resource
36+
// It will search for the same field in the response of the list
37+
// Field name will be stripped of the resource name (ex: cluster-id -> id)
38+
func AutocompleteGetArg(ctx context.Context, cmd *Command, argSpec *ArgSpec, completedArgs map[string]string) []string {
39+
commands := ExtractCommands(ctx)
40+
41+
// The argument we want to find (ex: server-id)
42+
argName := argSpec.Name
43+
argResource := cmd.Resource
44+
45+
// if arg name does not start with resource
46+
// ex with "scw instance private-nic list server-id=<tab>"
47+
// we get server as resource instead of private-nic to find command "scw instance server list"
48+
if !strings.HasPrefix(argName, cmd.Resource) {
49+
dashIndex := strings.Index(argName, "-")
50+
if dashIndex > 0 {
51+
argResource = argName[:dashIndex]
52+
}
53+
}
54+
55+
// skip if creating a resource and the arg to complete is from the same resource
56+
// does not complete name in "scw instance server create name=<tab>"
57+
// but still complete for different resources ex: "scw container container create namespace-id=<tab>"
58+
if cmd.Verb == "create" && argResource == cmd.Resource {
59+
return nil
60+
}
61+
62+
// remove resource from arg name (ex: server-id -> id)
63+
argName = strings.TrimPrefix(argName, argResource)
64+
argName = strings.TrimLeft(argName, "-")
65+
66+
listCmd, hasList := commands.find(cmd.Namespace, argResource, "list")
67+
if !hasList {
68+
return nil
69+
}
70+
71+
// Build empty arguments and run command
72+
// Has to use interceptor if it exists as ArgsType could be handled by interceptor
73+
listCmdArgs := reflect.New(listCmd.ArgsType).Interface()
74+
75+
// Keep zone and region arguments
76+
listRawArgs := []string(nil)
77+
for arg, value := range completedArgs {
78+
if strings.HasPrefix(arg, "zone") || strings.HasPrefix(arg, "region") {
79+
listRawArgs = append(listRawArgs, arg+value)
80+
}
81+
}
82+
83+
// Unmarshal args.
84+
// After that we are done working with rawArgs
85+
// and will be working with cmdArgs.
86+
err := args.UnmarshalStruct(listRawArgs, listCmdArgs)
87+
if err != nil {
88+
return nil
89+
}
90+
91+
if listCmd.Interceptor == nil {
92+
listCmd.Interceptor = func(ctx context.Context, argsI interface{}, runner CommandRunner) (interface{}, error) {
93+
return runner(ctx, argsI)
94+
}
95+
}
96+
resp, err := listCmd.Interceptor(ctx, listCmdArgs, listCmd.Run)
97+
if err != nil {
98+
return nil
99+
}
100+
101+
// As we run the "list" verb instead of using the sdk ListResource, response is already the slice
102+
// ex: ListServersResponse -> ListServersResponse.Servers
103+
resources := reflect.ValueOf(resp)
104+
if resources.Kind() != reflect.Slice {
105+
return nil
106+
}
107+
values := []string(nil)
108+
// Let's iterate over the struct in the response slice and get the searched field
109+
for i := 0; i < resources.Len(); i++ {
110+
resource := resources.Index(i)
111+
if resource.Kind() == reflect.Ptr {
112+
resource = resource.Elem()
113+
}
114+
resourceField := resource.FieldByName(strcase.ToPublicGoName(argName))
115+
if resourceField.Kind() == reflect.String {
116+
values = append(values, resourceField.String())
117+
}
118+
}
119+
120+
return values
121+
}

internal/core/bootstrap.go

+4
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ type BootstrapConfig struct {
6161
// Default HTTPClient to use. If not provided it will use a basic http client with a simple retry policy
6262
// This client will be used to create SDK client, account call, version checking and telemetry
6363
HTTPClient *http.Client
64+
65+
// Enable beta functionalities
66+
BetaMode bool
6467
}
6568

6669
// Bootstrap is the main entry point. It is directly called from main.
@@ -163,6 +166,7 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e
163166
command: nil, // command is later injected by cobra_utils.go/cobraRun()
164167
httpClient: httpClient,
165168
isClientFromBootstrapConfig: isClientFromBootstrapConfig,
169+
betaMode: config.BetaMode,
166170
}
167171
// We make sure OverrideEnv is never nil in meta.
168172
if meta.OverrideEnv == nil {

0 commit comments

Comments
 (0)