commit 5850247f1d958b7e9b21e2a3f2f451181df88773
parent 66e980f0b4bc7095b24ae74614e460aef234961f
Author: Sean Enck <sean@ttypty.com>
Date: Sat, 20 Apr 2024 08:16:39 -0400
completions should detect values
Diffstat:
12 files changed, 116 insertions(+), 218 deletions(-)
diff --git a/internal/app/completions.go b/internal/app/completions.go
@@ -3,7 +3,6 @@ package app
import (
"bytes"
- "errors"
"fmt"
"slices"
"strings"
@@ -27,32 +26,28 @@ type (
DoList string
Executable string
JSONCommand string
+ DefaultCompletion string
HelpCommand string
HelpAdvancedCommand string
- HelpShellCommand string
Profiles []Profile
- DefaultProfile Profile
Shell string
CompletionEnv string
+ IsYes string
+ DefaultProfile Profile
}
// Profile is a completion profile
Profile struct {
- Name string
- CanClip bool
- CanTOTP bool
- CanList bool
- ReadOnly bool
- IsDefault bool
- env []string
+ Name string
+ CanClip bool
+ CanTOTP bool
+ CanList bool
+ ReadOnly bool
+ IsDefault bool
+ Conditional string
}
)
-// Env will get the environment settable value to use this profile
-func (p Profile) Env() string {
- return fmt.Sprintf("%s=%s", config.EnvironmentCompletionKey, p.Display())
-}
-
// Options will list the profile options
func (p Profile) Options() []string {
opts := []string{EnvCommand, HelpCommand, ListCommand, ShowCommand, VersionCommand, JSONCommand}
@@ -68,11 +63,6 @@ func (p Profile) Options() []string {
return opts
}
-// Display is the profile display name
-func (p Profile) Display() string {
- return strings.Join(strings.Split(strings.ToUpper(p.Name), "-")[1:], "-")
-}
-
// TOTPSubCommands are the list of sub commands for TOTP within the profile
func (p Profile) TOTPSubCommands() []string {
totp := []string{TOTPMinimalCommand, TOTPOnceCommand, TOTPShowCommand}
@@ -85,11 +75,10 @@ func (p Profile) TOTPSubCommands() []string {
return totp
}
-func loadProfiles(exe string, canFilter bool) []Profile {
+func loadProfiles(exe string) []Profile {
profiles := config.LoadCompletionProfiles()
- filter := config.EnvCompletion.Get()
- hasFilter := filter != "" && canFilter
- var res []Profile
+ conditionals := make(map[int][]Profile)
+ maxCount := 0
for _, p := range profiles {
name := p.Name
if p.Default {
@@ -101,54 +90,31 @@ func loadProfiles(exe string, canFilter bool) []Profile {
n.CanTOTP = p.TOTP
n.ReadOnly = !p.Write
n.IsDefault = p.Default
- n.env = p.Env
- if hasFilter {
- skipped := false
- if p.Default {
- skipped = filter != "DEFAULT"
- } else {
- if filter != n.Display() {
- skipped = true
- }
- }
- if skipped {
- continue
- }
+ var sub []string
+ for _, e := range p.Env {
+ sub = append(sub, fmt.Sprintf("[ %s ]", e))
}
- res = append(res, n)
+ n.Conditional = strings.Join(sub, " && ")
+ count := len(p.Env)
+ val := conditionals[count]
+ conditionals[count] = append(val, n)
+ if count > maxCount {
+ maxCount = count
+ }
+ }
+ var res []Profile
+ for maxCount >= 0 {
+ val, ok := conditionals[maxCount]
+ if ok {
+ res = append(res, val...)
+ }
+ maxCount--
}
return res
}
// GenerateCompletions handles creating shell completion outputs
-func GenerateCompletions(completionType string, isHelp bool, exe string) ([]string, error) {
- if isHelp {
- h := []string{"completions are available for:"}
- for _, s := range completionTypes {
- h = append(h, fmt.Sprintf(" - %s", s))
- }
- h = append(h, "")
- for _, p := range loadProfiles(exe, false) {
- if p.IsDefault {
- continue
- }
- text := fmt.Sprintf("when %s\n - filtered completions\n - useful when:\n", p.Env())
- for idx, e := range p.env {
- if idx > 0 {
- text = fmt.Sprintf("%s and\n", text)
- }
- text = fmt.Sprintf("%s %s\n", text, e)
- }
- h = append(h, text)
- }
- h = append(h, strings.TrimSpace(fmt.Sprintf(`
-%s is not set
-unset %s
-when %s=<unknown>
- - default completions
-`, config.EnvironmentCompletionKey, config.EnvironmentCompletionKey, config.EnvironmentCompletionKey)))
- return h, nil
- }
+func GenerateCompletions(completionType, exe string) ([]string, error) {
if !slices.Contains(completionTypes, completionType) {
return nil, fmt.Errorf("unknown completion request: %s", completionType)
}
@@ -167,7 +133,8 @@ when %s=<unknown>
MoveCommand: MoveCommand,
DoList: fmt.Sprintf("%s %s", exe, ListCommand),
DoTOTPList: fmt.Sprintf("%s %s %s", exe, TOTPCommand, TOTPListCommand),
- CompletionEnv: fmt.Sprintf("$%s", config.EnvironmentCompletionKey),
+ DefaultCompletion: fmt.Sprintf("$%s", config.EnvDefaultCompletionKey),
+ IsYes: config.YesValue,
}
using, err := readDoc(fmt.Sprintf("%s.sh", completionType))
@@ -178,18 +145,11 @@ when %s=<unknown>
if err != nil {
return nil, err
}
- c.Profiles = loadProfiles(exe, true)
- switch len(c.Profiles) {
- case 0:
- return nil, errors.New("no profiles loaded, invalid environment setting?")
- case 1:
- c.DefaultProfile = c.Profiles[0]
- default:
- for _, p := range c.Profiles {
- if p.IsDefault {
- c.DefaultProfile = p
- break
- }
+ c.Profiles = loadProfiles(exe)
+ for _, p := range c.Profiles {
+ if p.IsDefault {
+ c.DefaultProfile = p
+ break
}
}
shell, err := templateScript(shellScript, c)
diff --git a/internal/app/completions_test.go b/internal/app/completions_test.go
@@ -1,81 +1,32 @@
package app_test
import (
- "fmt"
- "os"
+ "strings"
"testing"
"github.com/enckse/lockbox/internal/app"
)
func TestCompletions(t *testing.T) {
- testCompletion(t, "bash")
- testCompletion(t, "zsh")
- testCompletion(t, "fish")
- if _, err := app.GenerateCompletions("invalid", false, "lb"); err.Error() != "unknown completion request: invalid" {
- t.Errorf("invalid error: %v", err)
+ for k, v := range map[string]string{
+ "zsh": "typeset -A opt_args",
+ "fish": "set -l commands",
+ "bash": "local cur opts",
+ } {
+ testCompletion(t, k, v)
}
}
-func testCompletion(t *testing.T, completionMode string) {
- v, err := app.GenerateCompletions(completionMode, true, "lb")
- if err != nil {
- t.Errorf("invalid error: %v", err)
- }
- if len(v) < 2 {
- t.Errorf("invalid result")
- }
- defer os.Clearenv()
- os.Setenv("LOCKBOX_COMPLETION_FUNCTION", "A")
- o, err := app.GenerateCompletions(completionMode, true, "lb")
- if err != nil {
- t.Errorf("invalid error: %v", err)
- }
- if len(o) < 2 || len(o) != len(v) {
- t.Errorf("invalid result")
- }
- os.Setenv("LOCKBOX_COMPLETION_FUNCTION", "")
- v, err = app.GenerateCompletions(completionMode, false, "lb")
+func testCompletion(t *testing.T, completionMode, need string) {
+ v, err := app.GenerateCompletions(completionMode, "lb")
if err != nil {
t.Errorf("invalid error: %v", err)
}
if len(v) != 1 {
- t.Errorf("invalid result")
- }
- os.Setenv("LOCKBOX_COMPLETION_FUNCTION", "ZZZ")
- _, err = app.GenerateCompletions(completionMode, false, "lb")
- if err == nil || err.Error() != "no profiles loaded, invalid environment setting?" {
- t.Errorf("invalid error: %v", err)
- }
- os.Setenv("LOCKBOX_COMPLETION_FUNCTION", "NOCLIP-READONLY")
- n, err := app.GenerateCompletions(completionMode, false, "lb")
- if err != nil {
- t.Errorf("invalid error: %v", err)
- }
- if fmt.Sprintf("%v", n) == fmt.Sprintf("%v", v) {
- t.Errorf("invalid result, should filter")
+ t.Errorf("invalid result: %v", v)
}
- os.Setenv("LOCKBOX_COMPLETION_FUNCTION", "DEFAULT")
- d, err := app.GenerateCompletions(completionMode, false, "lb")
- if err != nil {
- t.Errorf("invalid error: %v", err)
- }
- if fmt.Sprintf("%v", d) == fmt.Sprintf("%v", v) || fmt.Sprintf("%v", d) == fmt.Sprintf("%v", n) {
- t.Errorf("invalid result, should filter")
- }
-}
-
-func TestProfileDisplay(t *testing.T) {
- p := app.Profile{Name: "_abc-test-awera-zzz"}
- if p.Display() != "TEST-AWERA-ZZZ" {
- t.Errorf("invalid display: %s", p.Display())
- }
-}
-
-func TestProfileEnv(t *testing.T) {
- p := app.Profile{Name: "_abc-test-awera-zzz"}
- if p.Env() != "LOCKBOX_COMPLETION_FUNCTION=TEST-AWERA-ZZZ" {
- t.Error("invalid env")
+ if !strings.Contains(v[0], need) {
+ t.Errorf("invalid output, bad shell generation: %v", v)
}
}
diff --git a/internal/app/core.go b/internal/app/core.go
@@ -59,8 +59,6 @@ const (
CompletionsBashCommand = "bash"
// CompletionsCommand are used to generate shell completions
CompletionsCommand = "completions"
- // CompletionsHelpCommand displays information about shell completions
- CompletionsHelpCommand = "help"
// ReKeyCommand will rekey the underlying database
ReKeyCommand = "rekey"
// MultiLineCommand handles multi-line inserts (when not piped)
@@ -101,13 +99,13 @@ type (
}
// Documentation is how documentation segments are templated
Documentation struct {
- Executable string
- MoveCommand string
- RemoveCommand string
- ReKeyCommand string
- CompletionsCommand string
- CompletionsHelpCommand string
- ReKey struct {
+ Executable string
+ MoveCommand string
+ RemoveCommand string
+ ReKeyCommand string
+ CompletionsCommand string
+ CompletionsEnv string
+ ReKey struct {
Store string
KeyFile string
Key string
@@ -191,7 +189,6 @@ func Usage(verbose bool, exe string) ([]string, error) {
var results []string
results = append(results, command(ClipCommand, "entry", "copy the entry's value into the clipboard"))
results = append(results, command(CompletionsCommand, "<shell>", "generate completions via auto-detection"))
- results = append(results, subCommand(CompletionsCommand, CompletionsHelpCommand, "", "show help information for completions"))
for _, c := range completionTypes {
results = append(results, subCommand(CompletionsCommand, c, "", fmt.Sprintf("generate %s completions", c)))
}
@@ -219,12 +216,12 @@ func Usage(verbose bool, exe string) ([]string, error) {
if verbose {
results = append(results, "")
document := Documentation{
- Executable: filepath.Base(exe),
- MoveCommand: MoveCommand,
- RemoveCommand: RemoveCommand,
- ReKeyCommand: ReKeyCommand,
- CompletionsCommand: CompletionsCommand,
- CompletionsHelpCommand: CompletionsHelpCommand,
+ Executable: filepath.Base(exe),
+ MoveCommand: MoveCommand,
+ RemoveCommand: RemoveCommand,
+ ReKeyCommand: ReKeyCommand,
+ CompletionsCommand: CompletionsCommand,
+ CompletionsEnv: config.EnvDefaultCompletionKey,
}
document.ReKey.Store = setDocFlag(config.ReKeyStoreFlag)
document.ReKey.Key = setDocFlag(config.ReKeyKeyFlag)
diff --git a/internal/app/core_test.go b/internal/app/core_test.go
@@ -9,11 +9,11 @@ import (
func TestUsage(t *testing.T) {
u, _ := app.Usage(false, "lb")
- if len(u) != 26 {
+ if len(u) != 25 {
t.Errorf("invalid usage, out of date? %d", len(u))
}
u, _ = app.Usage(true, "lb")
- if len(u) != 109 {
+ if len(u) != 107 {
t.Errorf("invalid verbose usage, out of date? %d", len(u))
}
for _, usage := range u {
diff --git a/internal/app/doc/completions.txt b/internal/app/doc/completions.txt
@@ -1,5 +1,4 @@
-Completions are available for certain shells and, by default, assume all
-features of `{{ $.Executable }}` are enabled and available. When changing certain environment
-flags it may be useful to change the completion profile to more closely match
-the restricted command options, run `{{ $.Executable }} {{ $.CompletionsCommand }} {{ $.CompletionsHelpCommand }}` for information
-on how best to alter completion outputs.
+Completions are available for certain shells and detect available completions
+determined by other environment settings. Completion command detection can be
+disabled by setting {{ $.CompletionsEnv }}. Generation of completions are
+handled by `{{ $.Executable }} {{ $.CompletionsCommand }}`
diff --git a/internal/app/doc/fish.sh b/internal/app/doc/fish.sh
@@ -26,19 +26,15 @@ end
{{- end}}
function {{ $.Executable }}-completions
-{{- if eq (len $.Profiles) 1 }}
- {{ $.DefaultProfile.Name }}
-{{- else}}
-{{- range $idx, $profile := $.Profiles }}
-{{- if not $profile.IsDefault }}
- if [ "{{ $.CompletionEnv }}" = "{{ $profile.Display }}" ]
- {{ $profile.Name }}
+{{- range $idx, $prof := $.Profiles }}
+{{- if not $prof.IsDefault }}
+ if {{ $prof.Conditional }}
+ {{ $prof.Name }}
return
end
{{- end}}
{{- end}}
{{ $.DefaultProfile.Name }}
-{{- end}}
end
{{ $.Executable }}-completions
diff --git a/internal/app/doc/shell.sh b/internal/app/doc/shell.sh
@@ -1,18 +1,13 @@
_{{ $.Executable }}() {
-{{- if eq (len $.Profiles) 1 }}
+ if [ -z "{{ $.DefaultCompletion }}" ] || [ "{{ $.DefaultCompletion }}" != "{{ $.IsYes }}" ]; then
+ {{- range $idx, $prof := $.Profiles }}
+ {{- if not $prof.IsDefault }}
+ if {{ $prof.Conditional }}; then
+ {{ $prof.Name }}
+ return
+ fi
+ {{- end }}
+ {{- end }}
+ fi
{{ $.DefaultProfile.Name }}
-{{- else}}
- case "{{ $.CompletionEnv }}" in
-{{- range $idx, $profile := $.Profiles }}
-{{- if not $profile.IsDefault }}
- "{{ $profile.Display }}")
- {{ $profile.Name }}
- ;;
-{{- end}}
-{{- end}}
- *)
- {{ $.DefaultProfile.Name }}
- ;;
- esac
-{{- end}}
}
diff --git a/internal/app/info.go b/internal/app/info.go
@@ -72,11 +72,7 @@ func info(command string, args []string) ([]string, error) {
case 0:
shell = filepath.Base(os.Getenv("SHELL"))
case 1:
- sub := args[0]
- if sub == CompletionsHelpCommand {
- return GenerateCompletions("", true, exe)
- }
- shell = sub
+ shell = args[0]
default:
return nil, errors.New("invalid completions subcommand")
}
@@ -86,7 +82,7 @@ func info(command string, args []string) ([]string, error) {
default:
return nil, fmt.Errorf("unknown completion type: %s", shell)
}
- return GenerateCompletions(shell, false, exe)
+ return GenerateCompletions(shell, exe)
}
return nil, nil
}
diff --git a/internal/app/info_test.go b/internal/app/info_test.go
@@ -99,25 +99,17 @@ func TestCompletionInfo(t *testing.T) {
}
}
}
- var buf bytes.Buffer
- ok, err := app.Info(&buf, "completions", []string{"help"})
- if !ok || err != nil {
- t.Errorf("invalid error: %v", err)
- }
- s := buf.String()
- if s == "" {
- t.Error("nothing written")
- }
- if _, err = app.Info(&buf, "completions", []string{"helps"}); err.Error() != "unknown completion type: helps" {
+ var buf bytes.Buffer
+ if _, err := app.Info(&buf, "completions", []string{"helps"}); err.Error() != "unknown completion type: helps" {
t.Errorf("invalid error: %v", err)
}
os.Clearenv()
os.Setenv("SHELL", "bad")
- if _, err = app.Info(&buf, "completions", []string{}); err.Error() != "unknown completion type: bad" {
+ if _, err := app.Info(&buf, "completions", []string{}); err.Error() != "unknown completion type: bad" {
t.Errorf("invalid error: %v", err)
}
- if _, err = app.Info(&buf, "completions", []string{"bash"}); err != nil {
+ if _, err := app.Info(&buf, "completions", []string{"bash"}); err != nil {
t.Errorf("invalid error: %v", err)
}
}
diff --git a/internal/config/core.go b/internal/config/core.go
@@ -44,6 +44,8 @@ const (
// sub categories
clipCategory keyCategory = "CLIP_"
totpCategory keyCategory = "TOTP_"
+ // YesValue are yes (on) values
+ YesValue = yes
)
var (
diff --git a/internal/config/vars.go b/internal/config/vars.go
@@ -30,7 +30,7 @@ const (
)
var (
- registry = []printer{EnvStore, envKeyMode, envKey, EnvNoClip, EnvNoColor, EnvInteractive, EnvReadOnly, EnvTOTPToken, EnvFormatTOTP, EnvMaxTOTP, EnvTOTPColorBetween, EnvClipPaste, EnvClipCopy, EnvClipMax, EnvPlatform, EnvNoTOTP, EnvHookDir, EnvClipOSC52, EnvKeyFile, EnvModTime, EnvJSONDataOutput, EnvHashLength, EnvConfig, envConfigExpands, EnvCompletion}
+ registry = []printer{EnvStore, envKeyMode, envKey, EnvNoClip, EnvNoColor, EnvInteractive, EnvReadOnly, EnvTOTPToken, EnvFormatTOTP, EnvMaxTOTP, EnvTOTPColorBetween, EnvClipPaste, EnvClipCopy, EnvClipMax, EnvPlatform, EnvNoTOTP, EnvHookDir, EnvClipOSC52, EnvKeyFile, EnvModTime, EnvJSONDataOutput, EnvHashLength, EnvConfig, envConfigExpands, EnvDefaultCompletion}
// Platforms represent the platforms that lockbox understands to run on
Platforms = []string{MacOSPlatform, WindowsLinuxPlatform, LinuxXPlatform, LinuxWaylandPlatform}
// TOTPDefaultColorWindow is the default coloring rules for totp
@@ -81,6 +81,16 @@ var (
},
defaultValue: false,
}
+ // EnvDefaultCompletion disable completion detection
+ EnvDefaultCompletion = EnvironmentBool{
+ environmentBase: environmentBase{
+ subKey: "DEFAULT_COMPLETION",
+ desc: "Use the default completion set (disable detection).",
+ },
+ defaultValue: false,
+ }
+ // EnvDefaultCompletionKey is the key for default completion handling
+ EnvDefaultCompletionKey = EnvDefaultCompletion.key()
// EnvNoColor indicates if color outputs are disabled
EnvNoColor = EnvironmentBool{environmentBase: environmentBase{
subKey: "NOCOLOR",
@@ -172,15 +182,7 @@ paths: %v
Note that this setting is not output as part of the environment.`, noEnvironment, detectEnvironment, detectEnvironmentPaths),
}, canDefault: true, defaultValue: detectEnvironment, allowed: []string{detectEnvironment, fileExample, noEnvironment}}
- // EnvCompletion is the completion method to use
- EnvCompletion = EnvironmentString{environmentBase: environmentBase{
- subKey: "COMPLETION_FUNCTION",
- desc: "Use to select the non-default completions, please review the shell completion help for more information.",
- requirement: "must be exported via a shell variable",
- }, canDefault: false}
- // EnvironmentCompletionKey is the underlying key for managing completion functions
- EnvironmentCompletionKey = EnvCompletion.key()
- envKeyMode = EnvironmentString{
+ envKeyMode = EnvironmentString{
environmentBase: environmentBase{
subKey: "KEYMODE", requirement: "must be set to a valid mode when using a key",
desc: fmt.Sprintf(`How to retrieve the database store password. Set to '%s' when only using a key file.
@@ -303,6 +305,10 @@ func ParseJSONOutput() (JSONOutputMode, error) {
return JSONDataOutputBlank, fmt.Errorf("invalid JSON output mode: %s", val)
}
+func exportProfileKeyValue(e environmentBase, val string) string {
+ return fmt.Sprintf("\"$%s\" = \"%s\"", e.key(), val)
+}
+
func newProfile(keys []string) CompletionProfile {
p := CompletionProfile{}
p.Clip = true
@@ -316,16 +322,16 @@ func newProfile(keys []string) CompletionProfile {
name = fmt.Sprintf("%s%s-", name, k)
switch k {
case askProfile:
- e = append(e, envKeyMode.KeyValue(string(askKeyMode)))
+ e = append(e, exportProfileKeyValue(envKeyMode.environmentBase, string(askKeyMode)))
p.List = false
case noTOTPProfile:
- e = append(e, EnvNoTOTP.KeyValue(yes))
+ e = append(e, exportProfileKeyValue(EnvNoTOTP.environmentBase, yes))
p.TOTP = false
case noClipProfile:
- e = append(e, EnvNoClip.KeyValue(yes))
+ e = append(e, exportProfileKeyValue(EnvNoClip.environmentBase, yes))
p.Clip = false
case roProfile:
- e = append(e, EnvReadOnly.KeyValue(yes))
+ e = append(e, exportProfileKeyValue(EnvReadOnly.environmentBase, yes))
p.Write = false
}
}
diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go
@@ -65,6 +65,10 @@ func TestIsNoClip(t *testing.T) {
checkYesNo("LOCKBOX_NOCLIP", t, config.EnvNoClip, false)
}
+func TestDefaultCompletions(t *testing.T) {
+ checkYesNo("LOCKBOX_DEFAULT_COMPLETION", t, config.EnvDefaultCompletion, false)
+}
+
func TestTOTP(t *testing.T) {
os.Setenv("LOCKBOX_TOTP", "abc")
if config.EnvTOTPToken.Get() != "abc" {