From e0a339e8ec996c0e91e27357854abd9a78bd695a Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Mon, 13 Jul 2026 13:18:08 -0400 Subject: [PATCH] feat: Added bash completion support. --- TODOS.md | 10 +-- cli.odin | 3 +- cmd_completion.odin | 157 ++++++++++++++++++++++++++++++++++++++- cmd_completion_test.odin | 55 ++++++++++++++ it.sh | 100 +++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 it.sh diff --git a/TODOS.md b/TODOS.md index 11ef98a..baa3c55 100644 --- a/TODOS.md +++ b/TODOS.md @@ -10,15 +10,13 @@ 5. Generate md and man pages again. -6. Bash Shell completion +6. Add tests for untested commands. -7. Add tests for untested commands. +7. Add uninstall command. -8. Add uninstall command. +8. Add purge command? -9. Add purge command? - -10. Ensure updates to db don't break hardlinks. +9. Ensure updates to db don't break hardlinks. ## Double-check AI output diff --git a/cli.odin b/cli.odin index 04cba59..83256d2 100644 --- a/cli.odin +++ b/cli.odin @@ -138,7 +138,8 @@ key somewhere, otherwise your data could be lost forever.`, short = "Generate shell completion scripts", long = `Supported shells: - nushell`, + nushell + bash`, flags = {.Help}, args = {{name = "shell", ntype = "string", completion = "shells"}}, }, diff --git a/cmd_completion.odin b/cmd_completion.odin index 20f38e7..5d549ed 100644 --- a/cmd_completion.odin +++ b/cmd_completion.odin @@ -29,7 +29,7 @@ def output [] { } def shells [] { - ['nushell'] + ['nushell' 'bash'] } ` @@ -43,9 +43,11 @@ cmd_completion :: proc(cmd: ^Command) { switch cmd.args[0] { case "nushell": fmt.wprint(cmd.out, generate_nushell_completion(), flush = true) + case "bash": + fmt.wprint(cmd.out, generate_bash_completion(), flush = true) case: fmt.wprintf(cmd.err, "Unsupported shell: %s\n", cmd.args[0]) - fmt.wprintln(cmd.err, "Supported shells: nushell") + fmt.wprintln(cmd.err, "Supported shells: nushell, bash") } } @@ -113,3 +115,154 @@ nushell_positional_line :: proc(arg: Positional_Arg) -> string { return fmt.tprintf("%s: %s", name, ntype) } +bash_header :: ` +_envr() { + local cur prev cmd + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + cmd="${COMP_WORDS[1]}" + +` + +bash_footer :: ` +} + +complete -F _envr envr +` + +generate_bash_completion :: proc() -> string { + sb: strings.Builder + strings.builder_init(&sb, context.temp_allocator) + defer strings.builder_destroy(&sb) + + fmt.sbprint(&sb, bash_header) + + // B: Subcommand completion + fmt.sbprintf(&sb, " if [[ $COMP_CWORD -eq 1 ]]; then\n") + fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"") + first := true + for c in COMMANDS { + if !first do fmt.sbprintf(&sb, " ") + fmt.sbprintf(&sb, "%s", c.name) + first = false + for a in c.aliases { + fmt.sbprintf(&sb, " %s", a) + } + } + fmt.sbprintf(&sb, "\" -- \"$cur\") )\n") + fmt.sbprintf(&sb, " return\n") + fmt.sbprintf(&sb, " fi\n\n") + + // C: Flag value completion + fmt.sbprintf(&sb, " case \"$prev\" in\n") + for ft in Flag_Type { + f := flag_field(ft) + if f.kind == .Bool do continue + + pattern := fmt.tprintf("--%s", f.long_name) + if len(f.short_name) > 0 { + pattern = fmt.tprintf("%s|-%s", pattern, f.short_name) + } + fmt.sbprintf(&sb, " %s)\n", pattern) + + switch f.kind { + case .Enum: + fmt.sbprintf( + &sb, + " COMPREPLY=( $(compgen -W \"%s\" -- \"$cur\") )\n", + bash_enum_values(f.enum_values), + ) + case .String: + fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -f -- \"$cur\") )\n") + case .Bool: + panic("unexpected") + } + fmt.sbprintf(&sb, " return\n") + fmt.sbprintf(&sb, " ;;\n") + } + fmt.sbprintf(&sb, " esac\n\n") + + // D: Flag name completion per command + fmt.sbprintf(&sb, " case \"$cur\" in -*)\n") + fmt.sbprintf(&sb, " case \"$cmd\" in\n") + for c in COMMANDS { + if c.flags == {} do continue + + cmd_pattern := c.name + for a in c.aliases { + cmd_pattern = fmt.tprintf("%s|%s", cmd_pattern, a) + } + fmt.sbprintf(&sb, " %s)\n", cmd_pattern) + fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"") + + flag_first := true + for ft in Flag_Type { + if ft not_in c.flags do continue + f := flag_field(ft) + if !flag_first do fmt.sbprintf(&sb, " ") + fmt.sbprintf(&sb, "--%s", f.long_name) + if len(f.short_name) > 0 { + fmt.sbprintf(&sb, " -%s", f.short_name) + } + flag_first = false + } + fmt.sbprintf(&sb, "\" -- \"$cur\") )\n") + fmt.sbprintf(&sb, " return\n") + fmt.sbprintf(&sb, " ;;\n") + } + fmt.sbprintf(&sb, " esac\n") + fmt.sbprintf(&sb, " ;;\n") + fmt.sbprintf(&sb, " esac\n\n") + + // E: Positional completion + fmt.sbprintf(&sb, " case \"$cmd\" in\n") + for c in COMMANDS { + has_comp := false + for arg in c.args { + if len(arg.completion) > 0 { + has_comp = true + break + } + } + if !has_comp do continue + + cmd_pattern := c.name + for a in c.aliases { + cmd_pattern = fmt.tprintf("%s|%s", cmd_pattern, a) + } + fmt.sbprintf(&sb, " %s)\n", cmd_pattern) + + for arg in c.args { + if len(arg.completion) == 0 do continue + + comp: string + switch arg.completion { + case "tracked-paths": + comp = "$(envr list --output json 2>/dev/null)" + case "untracked-paths": + comp = "$(envr scan --output json 2>/dev/null)" + case "shells": + comp = "nushell bash" + case: + continue + } + + fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"%s\" -- \"$cur\") )\n", comp) + } + fmt.sbprintf(&sb, " return\n") + fmt.sbprintf(&sb, " ;;\n") + } + fmt.sbprintf(&sb, " esac\n") + + fmt.sbprint(&sb, bash_footer) + + return strings.to_string(sb) +} + +bash_enum_values :: proc(enum_values: string) -> string { + s, _ := strings.replace(enum_values, "'", "", -1, context.temp_allocator) + s, _ = strings.replace(s, "|", " ", -1, context.temp_allocator) + return s +} + diff --git a/cmd_completion_test.odin b/cmd_completion_test.odin index 984b9d0..9c28853 100644 --- a/cmd_completion_test.odin +++ b/cmd_completion_test.odin @@ -76,3 +76,58 @@ test_nushell_completion_no_help_command :: proc(t: ^testing.T) { "script should not contain 'envr help' (not a real command)", ) } + +@(test) +test_bash_completion_nonempty :: proc(t: ^testing.T) { + script := generate_bash_completion() + testing.expect(t, len(script) > 0, "bash completion script should not be empty") +} + +@(test) +test_bash_completion_contains_registration :: proc(t: ^testing.T) { + script := generate_bash_completion() + testing.expect( + t, + strings.contains(script, "complete -F _envr envr"), + "expected bash script to register completion function", + ) +} + +@(test) +test_bash_completion_contains_commands :: proc(t: ^testing.T) { + script := generate_bash_completion() + expected := []string{ + "init", "scan", "sync", "backup", "add", + "restore", "list", "remove", "check", + "version", "edit-config", "completion", + } + for cmd in expected { + testing.expect( + t, + strings.contains(script, cmd), + fmt.tprintf("expected bash script to contain %q", cmd), + ) + } +} + +@(test) +test_bash_completion_contains_flags :: proc(t: ^testing.T) { + script := generate_bash_completion() + expected := []string{ + "--help", "--config-file", "--color", "--force", "--output", + } + for flag in expected { + testing.expect( + t, + strings.contains(script, flag), + fmt.tprintf("expected bash script to contain %q", flag), + ) + } +} + +@(test) +test_bash_completion_contains_enum_values :: proc(t: ^testing.T) { + script := generate_bash_completion() + testing.expect(t, strings.contains(script, "auto table json"), "missing output enum values") + testing.expect(t, strings.contains(script, "auto always never"), "missing color enum values") +} diff --git a/it.sh b/it.sh new file mode 100644 index 0000000..ccf4ea0 --- /dev/null +++ b/it.sh @@ -0,0 +1,100 @@ + +_envr() { + local cur prev cmd + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + cmd="${COMP_WORDS[1]}" + + if [[ $COMP_CWORD -eq 1 ]]; then + COMPREPLY=( $(compgen -W "init scan sync backup add restore list remove check version edit-config completion" -- "$cur") ) + return + fi + + case "$prev" in + --config-file|-c) + COMPREPLY=( $(compgen -f -- "$cur") ) + return + ;; + --output|-o) + COMPREPLY=( $(compgen -W "auto table json" -- "$cur") ) + return + ;; + --color) + COMPREPLY=( $(compgen -W "auto always never" -- "$cur") ) + return + ;; + esac + + case "$cur" in -*) + case "$cmd" in + init) + COMPREPLY=( $(compgen -W "--help -h --config-file -c --color --force -f" -- "$cur") ) + return + ;; + scan) + COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") ) + return + ;; + sync) + COMPREPLY=( $(compgen -W "--help -h --config-file -c --output -o --color" -- "$cur") ) + return + ;; + backup|add) + COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") ) + return + ;; + restore) + COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") ) + return + ;; + list) + COMPREPLY=( $(compgen -W "--help -h --config-file -c --output -o --color" -- "$cur") ) + return + ;; + remove) + COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") ) + return + ;; + check) + COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") ) + return + ;; + version) + COMPREPLY=( $(compgen -W "--help -h" -- "$cur") ) + return + ;; + edit-config) + COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") ) + return + ;; + completion) + COMPREPLY=( $(compgen -W "--help -h" -- "$cur") ) + return + ;; + esac + ;; + esac + + case "$cmd" in + backup|add) + COMPREPLY=( $(compgen -W "$(envr scan --output json 2>/dev/null)" -- "$cur") ) + return + ;; + restore) + COMPREPLY=( $(compgen -W "$(envr list --output json 2>/dev/null)" -- "$cur") ) + return + ;; + remove) + COMPREPLY=( $(compgen -W "$(envr list --output json 2>/dev/null)" -- "$cur") ) + return + ;; + completion) + COMPREPLY=( $(compgen -W "nushell bash" -- "$cur") ) + return + ;; + esac + +} + +complete -F _envr envr