Skip to content

Commit 4ac6a76

Browse files
authored
Merge pull request #392 from dokku/340-bug-file-autocompletion-never-suggests-recipe-files
fix: suggest recipe files in shell completion
2 parents c1df0ce + 8fe2b5b commit 4ac6a76

9 files changed

Lines changed: 185 additions & 15 deletions

File tree

commands/apply.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ func (c *ApplyCommand) AutocompleteFlags() complete.Flags {
115115
return command.MergeAutocompleteFlags(
116116
c.Meta.AutocompleteFlags(command.FlagSetClient),
117117
complete.Flags{
118-
"--tasks": complete.PredictFiles(taskFileAutocompleteGlob),
118+
"--tasks": taskFileAutocomplete(),
119119
"--verbose": complete.PredictNothing,
120120
"--json": complete.PredictNothing,
121121
"--host": complete.PredictAnything,

commands/export.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ func (c *ExportCommand) AutocompleteFlags() complete.Flags {
8686
return command.MergeAutocompleteFlags(
8787
c.Meta.AutocompleteFlags(command.FlagSetClient),
8888
complete.Flags{
89-
"--output": complete.PredictFiles(taskFileAutocompleteGlob),
90-
"--vars-output": complete.PredictFiles(taskFileAutocompleteGlob),
89+
"--output": taskFileAutocomplete(),
90+
"--vars-output": taskFileAutocomplete(),
9191
"--overwrite": complete.PredictNothing,
9292
"--redact": complete.PredictNothing,
9393
"--app": complete.PredictNothing,

commands/fmt.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func (c *FmtCommand) Arguments() []command.Argument {
6565
}
6666

6767
func (c *FmtCommand) AutocompleteArgs() complete.Predictor {
68-
return complete.PredictFiles(taskFileAutocompleteGlob)
68+
return taskFileAutocomplete()
6969
}
7070

7171
func (c *FmtCommand) ParsedArguments(args []string) (map[string]command.Argument, error) {

commands/init.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func (c *InitCommand) AutocompleteFlags() complete.Flags {
8686
return command.MergeAutocompleteFlags(
8787
c.Meta.AutocompleteFlags(command.FlagSetClient),
8888
complete.Flags{
89-
"--output": complete.PredictFiles(taskFileAutocompleteGlob),
89+
"--output": taskFileAutocomplete(),
9090
"--force": complete.PredictNothing,
9191
"--minimal": complete.PredictNothing,
9292
"--name": complete.PredictNothing,

commands/plan.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func (c *PlanCommand) AutocompleteFlags() complete.Flags {
112112
return command.MergeAutocompleteFlags(
113113
c.Meta.AutocompleteFlags(command.FlagSetClient),
114114
complete.Flags{
115-
"--tasks": complete.PredictFiles(taskFileAutocompleteGlob),
115+
"--tasks": taskFileAutocomplete(),
116116
"--json": complete.PredictNothing,
117117
"--detailed-exitcode": complete.PredictNothing,
118118
"--host": complete.PredictAnything,

commands/task_file.go

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"path/filepath"
1111
"strings"
1212
"time"
13+
14+
"github.com/posener/complete"
1315
)
1416

1517
// Task file format identifiers used throughout the commands package and
@@ -102,16 +104,21 @@ func fetchTaskFileURL(rawURL string) ([]byte, error) {
102104
return data, nil
103105
}
104106

107+
// taskFileExtensions lists the recipe file extensions docket recognises,
108+
// the single source of truth for hasTaskFileExtension and taskFileAutocomplete.
109+
var taskFileExtensions = []string{"yml", "yaml", "json", "json5"}
110+
105111
// hasTaskFileExtension reports whether path carries one of the recipe
106112
// file extensions. Used to spot a positional recipe path in an argv the
107113
// flag parser has not yet processed.
108114
func hasTaskFileExtension(path string) bool {
109-
switch strings.ToLower(filepath.Ext(path)) {
110-
case ".yml", ".yaml", ".json", ".json5":
111-
return true
112-
default:
113-
return false
115+
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), "."))
116+
for _, candidate := range taskFileExtensions {
117+
if ext == candidate {
118+
return true
119+
}
114120
}
121+
return false
115122
}
116123

117124
// resolveTaskFilePath returns the path to use as the task file plus its
@@ -156,6 +163,40 @@ func resolveTaskFileArg(explicit string, positional []string) (string, error) {
156163
return positional[0], nil
157164
}
158165

159-
// taskFileAutocompleteGlob is the shared file glob for the --tasks flag
160-
// completion across apply / plan / validate / fmt / init.
161-
const taskFileAutocompleteGlob = "*.{yml,yaml,json,json5}"
166+
// predictFilesByExtension returns a completion predictor offering files whose
167+
// name ends in one of the given extensions (each without a leading dot, e.g.
168+
// "yml"), plus directories for navigation, each offered exactly once.
169+
//
170+
// complete.PredictFiles feeds its pattern to filepath.Glob, whose
171+
// filepath.Match engine has no brace expansion, so a single "*.{yml,yaml}"
172+
// glob matches nothing (#340). Unioning one PredictFiles per extension
173+
// restores per-extension matching; the dedupe stops a directory (which every
174+
// sub-predictor lists) from being offered once per extension -- the library
175+
// prints every option without deduping (posener/complete complete.go output).
176+
func predictFilesByExtension(extensions []string) complete.Predictor {
177+
predictors := make([]complete.Predictor, 0, len(extensions))
178+
for _, ext := range extensions {
179+
predictors = append(predictors, complete.PredictFiles("*."+ext))
180+
}
181+
return complete.PredictFunc(func(a complete.Args) []string {
182+
seen := make(map[string]bool)
183+
var matches []string
184+
for _, p := range predictors {
185+
for _, match := range p.Predict(a) {
186+
if seen[match] {
187+
continue
188+
}
189+
seen[match] = true
190+
matches = append(matches, match)
191+
}
192+
}
193+
return matches
194+
})
195+
}
196+
197+
// taskFileAutocomplete is the file-completion predictor shared by the
198+
// --tasks / --output / --vars-output flags and `docket fmt`'s positional
199+
// argument across apply / plan / validate / fmt / init / export.
200+
func taskFileAutocomplete() complete.Predictor {
201+
return predictFilesByExtension(taskFileExtensions)
202+
}

commands/task_file_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"path/filepath"
66
"strings"
77
"testing"
8+
9+
"github.com/posener/complete"
810
)
911

1012
func TestDetectTaskFileFormat(t *testing.T) {
@@ -174,6 +176,87 @@ func TestResolveTaskFileFromArgsUsesExplicitFlag(t *testing.T) {
174176
}
175177
}
176178

179+
// TestTaskFileAutocompleteMatchesRecipeExtensions guards #340: the previous
180+
// brace glob "*.{yml,yaml,json,json5}" matched no file through filepath.Glob,
181+
// so completion only ever offered directories. Every recipe extension must now
182+
// be offered, a non-recipe file must not, and a directory must appear once
183+
// (the dedupe, since each per-extension sub-predictor lists it).
184+
func TestTaskFileAutocompleteMatchesRecipeExtensions(t *testing.T) {
185+
dir := t.TempDir()
186+
recipes := []string{"tasks.yml", "config.yaml", "data.json", "recipe.json5"}
187+
for _, name := range recipes {
188+
if err := os.WriteFile(filepath.Join(dir, name), []byte("---\n"), 0o644); err != nil {
189+
t.Fatalf("seed %s: %v", name, err)
190+
}
191+
}
192+
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil {
193+
t.Fatalf("seed notes.txt: %v", err)
194+
}
195+
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
196+
t.Fatalf("mkdir sub: %v", err)
197+
}
198+
199+
withCwd(t, dir, func() {
200+
counts := map[string]int{}
201+
for _, match := range taskFileAutocomplete().Predict(complete.Args{Last: ""}) {
202+
counts[match]++
203+
}
204+
for _, name := range recipes {
205+
if counts[name] == 0 {
206+
t.Errorf("expected %q to be offered, got %v", name, counts)
207+
}
208+
}
209+
if counts["notes.txt"] != 0 {
210+
t.Errorf("non-recipe notes.txt must not be offered, got %v", counts)
211+
}
212+
if counts["sub/"] != 1 {
213+
t.Errorf("directory sub/ should be offered exactly once, got %d (%v)", counts["sub/"], counts)
214+
}
215+
})
216+
}
217+
218+
// TestPredictFilesByExtension proves the completion mechanism is generic and
219+
// not hard-wired to the recipe extensions.
220+
func TestPredictFilesByExtension(t *testing.T) {
221+
dir := t.TempDir()
222+
for _, name := range []string{"readme.md", "todo.txt", "ignore.yml"} {
223+
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
224+
t.Fatalf("seed %s: %v", name, err)
225+
}
226+
}
227+
228+
withCwd(t, dir, func() {
229+
got := map[string]bool{}
230+
for _, match := range predictFilesByExtension([]string{"md", "txt"}).Predict(complete.Args{Last: ""}) {
231+
got[match] = true
232+
}
233+
if !got["readme.md"] {
234+
t.Errorf("expected readme.md to be offered, got %v", got)
235+
}
236+
if !got["todo.txt"] {
237+
t.Errorf("expected todo.txt to be offered, got %v", got)
238+
}
239+
if got["ignore.yml"] {
240+
t.Errorf("ignore.yml must not be offered for extensions {md,txt}, got %v", got)
241+
}
242+
})
243+
}
244+
245+
func TestHasTaskFileExtension(t *testing.T) {
246+
yes := []string{"tasks.yml", "tasks.YAML", "path/to/c.json", "x.json5"}
247+
no := []string{"notes.txt", "tasks", "archive.tar.gz", ""}
248+
for _, p := range yes {
249+
if !hasTaskFileExtension(p) {
250+
t.Errorf("hasTaskFileExtension(%q) = false, want true", p)
251+
}
252+
}
253+
for _, p := range no {
254+
if hasTaskFileExtension(p) {
255+
t.Errorf("hasTaskFileExtension(%q) = true, want false", p)
256+
}
257+
}
258+
}
259+
177260
// withCwd chdirs to dir for the duration of body and restores the
178261
// original cwd afterwards. Centralised so the resolveTaskFilePath
179262
// tests do not each handle the t.Cleanup dance.

commands/validate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func (c *ValidateCommand) AutocompleteFlags() complete.Flags {
9393
return command.MergeAutocompleteFlags(
9494
c.Meta.AutocompleteFlags(command.FlagSetClient),
9595
complete.Flags{
96-
"--tasks": complete.PredictFiles(taskFileAutocompleteGlob),
96+
"--tasks": taskFileAutocomplete(),
9797
"--json": complete.PredictNothing,
9898
"--strict": complete.PredictNothing,
9999
"--vars-file": complete.PredictFiles("*"),

tests/bats/completion.bats

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env bats
2+
#
3+
# Shell-completion regression tests for #340. docket enables autocompletion by
4+
# default (mitchellh/cli NewCLI sets Autocomplete: true) and satisfies the shell
5+
# completion protocol when COMP_LINE is set, so completing a recipe-file flag or
6+
# argument runs the real predictor. The previous brace glob
7+
# "*.{yml,yaml,json,json5}" matched no file through Go's filepath.Glob, so only
8+
# directories were ever offered; these tests assert real recipe files come back.
9+
#
10+
# COMP_POINT is intentionally omitted: posener/complete falls back to the end of
11+
# COMP_LINE, so the trailing space makes the word being completed empty and every
12+
# candidate file matches.
13+
14+
load test_helper
15+
16+
setup() {
17+
docket_build
18+
}
19+
20+
@test "docket apply --tasks completes recipe files but not other files (#340)" {
21+
cd "$BATS_TEST_TMPDIR"
22+
: >tasks.yml
23+
: >config.yaml
24+
: >data.json
25+
: >recipe.json5
26+
: >notes.txt
27+
export COMP_LINE='docket apply --tasks '
28+
run "$(docket_bin)" apply --tasks
29+
assert_success
30+
assert_output --partial 'tasks.yml'
31+
assert_output --partial 'config.yaml'
32+
assert_output --partial 'data.json'
33+
assert_output --partial 'recipe.json5'
34+
refute_output --partial 'notes.txt'
35+
}
36+
37+
@test "docket fmt completes recipe files positionally (#340)" {
38+
cd "$BATS_TEST_TMPDIR"
39+
: >tasks.yml
40+
: >notes.txt
41+
export COMP_LINE='docket fmt '
42+
run "$(docket_bin)" fmt
43+
assert_success
44+
assert_output --partial 'tasks.yml'
45+
refute_output --partial 'notes.txt'
46+
}

0 commit comments

Comments
 (0)