Loading dclish/builder.go 0 → 100644 +44 −0 Original line number Diff line number Diff line package dclish import ( "sort" "strings" ) // BuildCommandTable converts a Commands map into a sorted CommandTable. // It recursively builds CommandTable and FlagTable for each command. func BuildCommandTable(commands Commands) CommandTable { ct := make(CommandTable, 0, len(commands)) for name, cmd := range commands { name = strings.ToUpper(name) if len(cmd.Commands) > 0 { cmd.CommandTable = BuildCommandTable(cmd.Commands) } if len(cmd.Flags) > 0 { cmd.FlagTable = BuildFlagTable(cmd.Flags) } ct = append(ct, CommandEntry{Name: name, Command: cmd}) } sort.Slice(ct, func(i, j int) bool { return ct[i].Name < ct[j].Name }) return ct } // BuildFlagTable converts a Flags map into a sorted FlagTable. // For non-OptArg flags, it auto-generates /NO negated entries. func BuildFlagTable(flags Flags) FlagTable { ft := make(FlagTable, 0, len(flags)*2) for name, flg := range flags { name = strings.ToUpper(name) ft = append(ft, FlagEntry{Name: name, Flag: flg, Negated: false}) if !flg.OptArg { noName := strings.Replace(name, "/", "/NO", 1) ft = append(ft, FlagEntry{Name: noName, Flag: flg, Negated: true}) } } sort.Slice(ft, func(i, j int) bool { return ft[i].Name < ft[j].Name }) return ft } dclish/completer.go +41 −34 Original line number Diff line number Diff line package dclish import ( "sort" "strings" "unicode" ) Loading @@ -8,35 +9,23 @@ import ( // Completer command completer type. type Completer struct { commands []string flags map[string][]string flags map[string]FlagTable } // NewCompleter creates a new completer. func NewCompleter(commands Commands) Completer { // NewCompleter creates a new completer from a CommandTable. func NewCompleter(ct CommandTable) Completer { comps := []string{} flags := map[string][]string{} for c := range commands { comps = append(comps, c) if len(commands[c].Commands) > 0 { subcommands := commands[c].Commands for subc := range subcommands { fullc := c + " " + subc flags := map[string]FlagTable{} for _, entry := range ct { comps = append(comps, entry.Name) flags[entry.Name] = entry.Command.FlagTable for _, sub := range entry.Command.CommandTable { fullc := entry.Name + " " + sub.Name comps = append(comps, fullc) flags[fullc] = []string{} if subcommands[subc].Flags != nil { for f := range subcommands[subc].Flags { flags[fullc] = append(flags[fullc], f) } } } } flags[c] = []string{} if commands[c].Flags != nil { for f := range commands[c].Flags { flags[c] = append(flags[c], f) } flags[fullc] = sub.Command.FlagTable } } sort.Strings(comps) return Completer{ commands: comps, flags: flags, Loading @@ -54,16 +43,38 @@ func (c Completer) Do(line []rune, pos int) ([][]rune, int) { return newline, pos } // Command partially typed in. input := string(line[0:pos]) cmd := strings.ToUpper(input) lower := unicode.IsLower(line[0]) // Check if the user is typing a flag (input contains a /). if idx := strings.LastIndex(input, "/"); idx >= 0 { // Extract the command part before the flag. cmdPart := strings.TrimSpace(input[:idx]) flagPart := strings.ToUpper(input[idx:]) // Find matching command for flag lookup. cmdKey := strings.ToUpper(cmdPart) if ft, ok := c.flags[cmdKey]; ok { matches := ft.Completions(flagPart) newline := [][]rune{} cmd := strings.ToUpper(string(line[0:pos])) lower := false if unicode.IsLower(line[0]) { lower = true for _, m := range matches { rest := m[len(flagPart):] if lower { newline = append(newline, []rune(strings.ToLower(rest))) } else { newline = append(newline, []rune(rest)) } } return newline, pos } } // Command partially typed in. newline := [][]rune{} for i := range c.commands { if strings.HasPrefix(c.commands[i], cmd) { rest := strings.Replace(c.commands[i], cmd, "", 1) rest := c.commands[i][len(cmd):] if lower { newline = append(newline, []rune(strings.ToLower(rest))) } else { Loading @@ -71,10 +82,6 @@ func (c Completer) Do(line []rune, pos int) ([][]rune, int) { } } } if len(newline) > 0 { return newline, pos } // Command completely typed in. return newline, pos } dclish/dclish.go +54 −51 Original line number Diff line number Diff line Loading @@ -64,10 +64,12 @@ type Flags map[string]*Flag // be limited. type Command struct { Flags Flags FlagTable FlagTable Args []string MaxArgs int MinArgs int Commands Commands CommandTable CommandTable Action ActionFunc Completer CompleterFunc Description string Loading @@ -76,7 +78,9 @@ type Command struct { // Commands is the full list of commands. type Commands map[string]*Command func split(line string) []string { // Split tokenizes a command line into words. Handles quoted strings // and slash-separated flags. func Split(line string) []string { words := []string{} buf := strings.Builder{} state := "start" Loading Loading @@ -167,51 +171,40 @@ func PrefixMatch(command string, commands []string) (string, error) { } } // ParseAndRun parses a command line and runs the command. func (c Commands) ParseAndRun(line string) error { // Split into words. words := split(line) return c.run(words) // ParseAndRun parses a command line and runs the command using // the CommandTable (sorted slice) approach. func (ct CommandTable) ParseAndRun(line string) error { words := Split(line) if len(words) == 0 { return nil } return ct.run(words) } func (c Commands) run(words []string) error { func (ct CommandTable) run(words []string) error { // Find the command. wordup := strings.ToUpper(words[0]) cmd, ok := c[wordup] if !ok { possibles := []string{} for word := range c { if strings.HasPrefix(word, wordup) { possibles = append(possibles, word) } } switch len(possibles) { case 0: return fmt.Errorf("unknown command '%s'", words[0]) case 1: wordup = possibles[0] cmd = c[wordup] default: return fmt.Errorf("ambiguous command '%s' (matches %s)", words[0], strings.Join(possibles, ", ")) } entry, err := ct.Find(words[0]) if err != nil { return err } cmd := entry.Command // Deal with subcommands. if len(cmd.Commands) > 0 { if len(cmd.CommandTable) > 0 { if len(words) == 1 { if cmd.Action == nil { return fmt.Errorf("missing subcommand for %s", wordup) return fmt.Errorf("missing subcommand for %s", entry.Name) } return cmd.Action(cmd) } return cmd.Commands.run(words[1:]) return cmd.CommandTable.run(words[1:]) } if cmd.Action == nil { return fmt.Errorf("command not implemented:\n%s", cmd.Description) } // Reset flags to defaults. for flg := range cmd.Flags { cmd.Flags[flg].Value = cmd.Flags[flg].Default cmd.Flags[flg].Set = false Loading @@ -224,26 +217,24 @@ func (c Commands) run(words []string) error { } return cmd.Action(cmd) } args := words[1:] for i := range args { if strings.HasPrefix(args[i], "/") { flag, val, assigned := strings.Cut(args[i], "=") var wordup string var lookup string if assigned { wordup = strings.ToUpper(flag) lookup = strings.ToUpper(flag) } else { wordup = strings.ToUpper(args[i]) } toggleValue := "true" flg, ok := cmd.Flags[wordup] if !ok { wordup = strings.Replace(wordup, "/NO", "/", 1) flg, ok = cmd.Flags[wordup] if !ok { return fmt.Errorf("flag '%s' not recognised", args[i]) lookup = strings.ToUpper(args[i]) } toggleValue = "false" fe, err := cmd.FlagTable.Find(lookup) if err != nil { return fmt.Errorf("flag '%s' not recognised", args[i]) } flg := fe.Flag if !flg.OptArg && assigned { return fmt.Errorf("flag '%s' is a toggle", args[i]) } Loading @@ -256,7 +247,11 @@ func (c Commands) run(words []string) error { flg.Value = strings.Trim(val, "\"'") } } else { flg.Value = toggleValue if fe.Negated { flg.Value = "false" } else { flg.Value = "true" } } } else { if len(cmd.Args) == cmd.MaxArgs { Loading @@ -270,3 +265,11 @@ func (c Commands) run(words []string) error { } return cmd.Action(cmd) } // ParseAndRun parses a command line and runs the command. // This is a backward-compatible wrapper that builds a CommandTable // and delegates to it. func (c Commands) ParseAndRun(line string) error { ct := BuildCommandTable(c) return ct.ParseAndRun(line) } dclish/dclish_test.go 0 → 100644 +276 −0 Original line number Diff line number Diff line package dclish import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSplit(t *testing.T) { tests := []struct { name string input string expected []string }{ {"empty", "", []string{}}, {"single word", "READ", []string{"READ"}}, {"two words", "READ 5", []string{"READ", "5"}}, {"flag", "DIR/FOLDERS", []string{"DIR", "/FOLDERS"}}, {"flag with value", "ADD/SUBJECT=hello", []string{"ADD", "/SUBJECT=hello"}}, {"double quoted", `ADD "hello world"`, []string{"ADD", "hello world"}}, {"single quoted", "ADD 'hello world'", []string{"ADD", "hello world"}}, {"multiple flags", "DIR/FOLDERS/NEW", []string{"DIR", "/FOLDERS", "/NEW"}}, {"spaces everywhere", " READ 5 ", []string{"READ", "5"}}, {"flag with quoted value", `ADD/SUBJECT="hello world"`, []string{"ADD", `/SUBJECT="hello world"`}}, {"slash in value", "SET ACCESS/ALL", []string{"SET", "ACCESS", "/ALL"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := Split(tt.input) assert.Equal(t, tt.expected, result) }) } } func TestCommandTableFind(t *testing.T) { ct := CommandTable{ {Name: "ADD", Command: &Command{}}, {Name: "BACK", Command: &Command{}}, {Name: "DELETE", Command: &Command{}}, {Name: "DIRECTORY", Command: &Command{}}, {Name: "EXIT", Command: &Command{}}, } tests := []struct { name string prefix string wantName string wantErr bool errSubstr string }{ {"exact match", "ADD", "ADD", false, ""}, {"exact match case", "add", "ADD", false, ""}, {"unique prefix", "BA", "BACK", false, ""}, {"unique prefix EX", "EX", "EXIT", false, ""}, {"ambiguous", "D", "", true, "ambiguous"}, {"not found", "ZZZ", "", true, "unknown"}, {"exact DIR match", "DIRECTORY", "DIRECTORY", false, ""}, {"unique DEL prefix", "DEL", "DELETE", false, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { entry, err := ct.Find(tt.prefix) if tt.wantErr { require.Error(t, err) assert.Contains(t, err.Error(), tt.errSubstr) } else { require.NoError(t, err) assert.Equal(t, tt.wantName, entry.Name) } }) } } func TestFlagTableFind(t *testing.T) { flags := Flags{ "/ALL": {Description: "all"}, "/BELL": {Description: "bell"}, "/SUBJECT": {OptArg: true, Description: "subject"}, } ft := BuildFlagTable(flags) tests := []struct { name string prefix string wantName string negated bool wantErr bool errSubstr string }{ {"exact positive", "/ALL", "/ALL", false, false, ""}, {"exact negated", "/NOALL", "/NOALL", true, false, ""}, {"optarg no negation", "/SUBJECT", "/SUBJECT", false, false, ""}, {"optarg prefix", "/SU", "/SUBJECT", false, false, ""}, {"negated prefix", "/NOB", "/NOBELL", true, false, ""}, {"not found", "/ZZZ", "", false, true, "not recognised"}, {"case insensitive", "/all", "/ALL", false, false, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { entry, err := ft.Find(tt.prefix) if tt.wantErr { require.Error(t, err) assert.Contains(t, err.Error(), tt.errSubstr) } else { require.NoError(t, err) assert.Equal(t, tt.wantName, entry.Name) assert.Equal(t, tt.negated, entry.Negated) } }) } } func TestBuildFlagTable(t *testing.T) { flags := Flags{ "/FOO": {Description: "toggle foo"}, "/BAR": {OptArg: true, Description: "opt bar"}, "/VERBOSE": {Description: "verbose"}, } ft := BuildFlagTable(flags) // Toggle flags should have /NO entries. names := make([]string, len(ft)) for i, e := range ft { names[i] = e.Name } assert.Contains(t, names, "/FOO") assert.Contains(t, names, "/NOFOO") assert.Contains(t, names, "/VERBOSE") assert.Contains(t, names, "/NOVERBOSE") // OptArg should NOT have /NO entry. assert.Contains(t, names, "/BAR") assert.NotContains(t, names, "/NOBAR") } func TestCommandTableRun(t *testing.T) { var lastCmd string var lastArgs []string var lastFlags map[string]string action := func(cmd *Command) error { lastCmd = "called" lastArgs = cmd.Args lastFlags = map[string]string{} for k, v := range cmd.Flags { if v.Set { lastFlags[k] = v.Value } } return nil } commands := Commands{ "ADD": { Action: action, MaxArgs: 1, Flags: Flags{ "/SUBJECT": {OptArg: true}, "/BELL": {}, }, }, "SHOW": { Commands: Commands{ "VERSION": {Action: action}, "FOLDER": {Action: action, MaxArgs: 1}, }, }, } ct := BuildCommandTable(commands) t.Run("simple command", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("ADD myarg") require.NoError(t, err) assert.Equal(t, "called", lastCmd) assert.Equal(t, []string{"myarg"}, lastArgs) }) t.Run("command with flag", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("ADD/SUBJECT=hello") require.NoError(t, err) assert.Equal(t, "called", lastCmd) assert.Equal(t, "hello", lastFlags["/SUBJECT"]) }) t.Run("negated flag", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("ADD /NOBELL") require.NoError(t, err) assert.Equal(t, "called", lastCmd) assert.Equal(t, "false", lastFlags["/BELL"]) }) t.Run("prefix match", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("AD myarg") require.NoError(t, err) assert.Equal(t, "called", lastCmd) }) t.Run("subcommand", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("SHOW VERSION") require.NoError(t, err) assert.Equal(t, "called", lastCmd) }) t.Run("subcommand prefix", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("SH VER") require.NoError(t, err) assert.Equal(t, "called", lastCmd) }) t.Run("too many args", func(t *testing.T) { err := ct.ParseAndRun("ADD arg1 arg2") require.Error(t, err) assert.Contains(t, err.Error(), "too many args") }) t.Run("unknown command", func(t *testing.T) { err := ct.ParseAndRun("ZZZZ") require.Error(t, err) assert.Contains(t, err.Error(), "unknown") }) } func TestCompletions(t *testing.T) { ct := CommandTable{ {Name: "ADD", Command: &Command{}}, {Name: "BACK", Command: &Command{}}, {Name: "DELETE", Command: &Command{}}, {Name: "DIRECTORY", Command: &Command{}}, } t.Run("prefix D", func(t *testing.T) { result := ct.Completions("D") assert.Equal(t, []string{"DELETE", "DIRECTORY"}, result) }) t.Run("prefix A", func(t *testing.T) { result := ct.Completions("A") assert.Equal(t, []string{"ADD"}, result) }) t.Run("prefix Z", func(t *testing.T) { result := ct.Completions("Z") assert.Empty(t, result) }) t.Run("empty prefix", func(t *testing.T) { result := ct.Completions("") assert.Len(t, result, 4) }) } func TestFlagTableCompletions(t *testing.T) { flags := Flags{ "/ALL": {}, "/BELL": {}, "/SUBJECT": {OptArg: true}, } ft := BuildFlagTable(flags) t.Run("prefix /A", func(t *testing.T) { result := ft.Completions("/A") assert.Equal(t, []string{"/ALL"}, result) }) t.Run("prefix /NO", func(t *testing.T) { result := ft.Completions("/NO") assert.Contains(t, result, "/NOALL") assert.Contains(t, result, "/NOBELL") }) } dclish/table.go 0 → 100644 +98 −0 Original line number Diff line number Diff line package dclish import ( "fmt" "sort" "strings" ) // CommandEntry is an entry in a CommandTable. type CommandEntry struct { Name string Command *Command } // CommandTable is a sorted slice of CommandEntry for binary search lookup. type CommandTable []CommandEntry // FlagEntry is an entry in a FlagTable. type FlagEntry struct { Name string Flag *Flag Negated bool } // FlagTable is a sorted slice of FlagEntry for binary search lookup. type FlagTable []FlagEntry // Find locates a command by exact match or unique prefix. func (ct CommandTable) Find(prefix string) (CommandEntry, error) { prefix = strings.ToUpper(prefix) i := sort.Search(len(ct), func(i int) bool { return ct[i].Name >= prefix }) if i < len(ct) && ct[i].Name == prefix { return ct[i], nil } if i >= len(ct) || !strings.HasPrefix(ct[i].Name, prefix) { return CommandEntry{}, fmt.Errorf("unknown command '%s'", prefix) } if i+1 < len(ct) && strings.HasPrefix(ct[i+1].Name, prefix) { possibles := []string{} for j := i; j < len(ct) && strings.HasPrefix(ct[j].Name, prefix); j++ { possibles = append(possibles, ct[j].Name) } return CommandEntry{}, fmt.Errorf("ambiguous command '%s' (matches %s)", prefix, strings.Join(possibles, ", ")) } return ct[i], nil } // Completions returns all command names matching the given prefix. func (ct CommandTable) Completions(prefix string) []string { prefix = strings.ToUpper(prefix) i := sort.Search(len(ct), func(i int) bool { return ct[i].Name >= prefix }) var result []string for j := i; j < len(ct) && strings.HasPrefix(ct[j].Name, prefix); j++ { result = append(result, ct[j].Name) } return result } // Find locates a flag by exact match or unique prefix. func (ft FlagTable) Find(prefix string) (FlagEntry, error) { prefix = strings.ToUpper(prefix) i := sort.Search(len(ft), func(i int) bool { return ft[i].Name >= prefix }) if i < len(ft) && ft[i].Name == prefix { return ft[i], nil } if i >= len(ft) || !strings.HasPrefix(ft[i].Name, prefix) { return FlagEntry{}, fmt.Errorf("flag '%s' not recognised", prefix) } if i+1 < len(ft) && strings.HasPrefix(ft[i+1].Name, prefix) { possibles := []string{} for j := i; j < len(ft) && strings.HasPrefix(ft[j].Name, prefix); j++ { possibles = append(possibles, ft[j].Name) } return FlagEntry{}, fmt.Errorf("ambiguous flag '%s' (matches %s)", prefix, strings.Join(possibles, ", ")) } return ft[i], nil } // Completions returns all flag names matching the given prefix. func (ft FlagTable) Completions(prefix string) []string { prefix = strings.ToUpper(prefix) i := sort.Search(len(ft), func(i int) bool { return ft[i].Name >= prefix }) var result []string for j := i; j < len(ft) && strings.HasPrefix(ft[j].Name, prefix); j++ { result = append(result, ft[j].Name) } return result } Loading
dclish/builder.go 0 → 100644 +44 −0 Original line number Diff line number Diff line package dclish import ( "sort" "strings" ) // BuildCommandTable converts a Commands map into a sorted CommandTable. // It recursively builds CommandTable and FlagTable for each command. func BuildCommandTable(commands Commands) CommandTable { ct := make(CommandTable, 0, len(commands)) for name, cmd := range commands { name = strings.ToUpper(name) if len(cmd.Commands) > 0 { cmd.CommandTable = BuildCommandTable(cmd.Commands) } if len(cmd.Flags) > 0 { cmd.FlagTable = BuildFlagTable(cmd.Flags) } ct = append(ct, CommandEntry{Name: name, Command: cmd}) } sort.Slice(ct, func(i, j int) bool { return ct[i].Name < ct[j].Name }) return ct } // BuildFlagTable converts a Flags map into a sorted FlagTable. // For non-OptArg flags, it auto-generates /NO negated entries. func BuildFlagTable(flags Flags) FlagTable { ft := make(FlagTable, 0, len(flags)*2) for name, flg := range flags { name = strings.ToUpper(name) ft = append(ft, FlagEntry{Name: name, Flag: flg, Negated: false}) if !flg.OptArg { noName := strings.Replace(name, "/", "/NO", 1) ft = append(ft, FlagEntry{Name: noName, Flag: flg, Negated: true}) } } sort.Slice(ft, func(i, j int) bool { return ft[i].Name < ft[j].Name }) return ft }
dclish/completer.go +41 −34 Original line number Diff line number Diff line package dclish import ( "sort" "strings" "unicode" ) Loading @@ -8,35 +9,23 @@ import ( // Completer command completer type. type Completer struct { commands []string flags map[string][]string flags map[string]FlagTable } // NewCompleter creates a new completer. func NewCompleter(commands Commands) Completer { // NewCompleter creates a new completer from a CommandTable. func NewCompleter(ct CommandTable) Completer { comps := []string{} flags := map[string][]string{} for c := range commands { comps = append(comps, c) if len(commands[c].Commands) > 0 { subcommands := commands[c].Commands for subc := range subcommands { fullc := c + " " + subc flags := map[string]FlagTable{} for _, entry := range ct { comps = append(comps, entry.Name) flags[entry.Name] = entry.Command.FlagTable for _, sub := range entry.Command.CommandTable { fullc := entry.Name + " " + sub.Name comps = append(comps, fullc) flags[fullc] = []string{} if subcommands[subc].Flags != nil { for f := range subcommands[subc].Flags { flags[fullc] = append(flags[fullc], f) } } } } flags[c] = []string{} if commands[c].Flags != nil { for f := range commands[c].Flags { flags[c] = append(flags[c], f) } flags[fullc] = sub.Command.FlagTable } } sort.Strings(comps) return Completer{ commands: comps, flags: flags, Loading @@ -54,16 +43,38 @@ func (c Completer) Do(line []rune, pos int) ([][]rune, int) { return newline, pos } // Command partially typed in. input := string(line[0:pos]) cmd := strings.ToUpper(input) lower := unicode.IsLower(line[0]) // Check if the user is typing a flag (input contains a /). if idx := strings.LastIndex(input, "/"); idx >= 0 { // Extract the command part before the flag. cmdPart := strings.TrimSpace(input[:idx]) flagPart := strings.ToUpper(input[idx:]) // Find matching command for flag lookup. cmdKey := strings.ToUpper(cmdPart) if ft, ok := c.flags[cmdKey]; ok { matches := ft.Completions(flagPart) newline := [][]rune{} cmd := strings.ToUpper(string(line[0:pos])) lower := false if unicode.IsLower(line[0]) { lower = true for _, m := range matches { rest := m[len(flagPart):] if lower { newline = append(newline, []rune(strings.ToLower(rest))) } else { newline = append(newline, []rune(rest)) } } return newline, pos } } // Command partially typed in. newline := [][]rune{} for i := range c.commands { if strings.HasPrefix(c.commands[i], cmd) { rest := strings.Replace(c.commands[i], cmd, "", 1) rest := c.commands[i][len(cmd):] if lower { newline = append(newline, []rune(strings.ToLower(rest))) } else { Loading @@ -71,10 +82,6 @@ func (c Completer) Do(line []rune, pos int) ([][]rune, int) { } } } if len(newline) > 0 { return newline, pos } // Command completely typed in. return newline, pos }
dclish/dclish.go +54 −51 Original line number Diff line number Diff line Loading @@ -64,10 +64,12 @@ type Flags map[string]*Flag // be limited. type Command struct { Flags Flags FlagTable FlagTable Args []string MaxArgs int MinArgs int Commands Commands CommandTable CommandTable Action ActionFunc Completer CompleterFunc Description string Loading @@ -76,7 +78,9 @@ type Command struct { // Commands is the full list of commands. type Commands map[string]*Command func split(line string) []string { // Split tokenizes a command line into words. Handles quoted strings // and slash-separated flags. func Split(line string) []string { words := []string{} buf := strings.Builder{} state := "start" Loading Loading @@ -167,51 +171,40 @@ func PrefixMatch(command string, commands []string) (string, error) { } } // ParseAndRun parses a command line and runs the command. func (c Commands) ParseAndRun(line string) error { // Split into words. words := split(line) return c.run(words) // ParseAndRun parses a command line and runs the command using // the CommandTable (sorted slice) approach. func (ct CommandTable) ParseAndRun(line string) error { words := Split(line) if len(words) == 0 { return nil } return ct.run(words) } func (c Commands) run(words []string) error { func (ct CommandTable) run(words []string) error { // Find the command. wordup := strings.ToUpper(words[0]) cmd, ok := c[wordup] if !ok { possibles := []string{} for word := range c { if strings.HasPrefix(word, wordup) { possibles = append(possibles, word) } } switch len(possibles) { case 0: return fmt.Errorf("unknown command '%s'", words[0]) case 1: wordup = possibles[0] cmd = c[wordup] default: return fmt.Errorf("ambiguous command '%s' (matches %s)", words[0], strings.Join(possibles, ", ")) } entry, err := ct.Find(words[0]) if err != nil { return err } cmd := entry.Command // Deal with subcommands. if len(cmd.Commands) > 0 { if len(cmd.CommandTable) > 0 { if len(words) == 1 { if cmd.Action == nil { return fmt.Errorf("missing subcommand for %s", wordup) return fmt.Errorf("missing subcommand for %s", entry.Name) } return cmd.Action(cmd) } return cmd.Commands.run(words[1:]) return cmd.CommandTable.run(words[1:]) } if cmd.Action == nil { return fmt.Errorf("command not implemented:\n%s", cmd.Description) } // Reset flags to defaults. for flg := range cmd.Flags { cmd.Flags[flg].Value = cmd.Flags[flg].Default cmd.Flags[flg].Set = false Loading @@ -224,26 +217,24 @@ func (c Commands) run(words []string) error { } return cmd.Action(cmd) } args := words[1:] for i := range args { if strings.HasPrefix(args[i], "/") { flag, val, assigned := strings.Cut(args[i], "=") var wordup string var lookup string if assigned { wordup = strings.ToUpper(flag) lookup = strings.ToUpper(flag) } else { wordup = strings.ToUpper(args[i]) } toggleValue := "true" flg, ok := cmd.Flags[wordup] if !ok { wordup = strings.Replace(wordup, "/NO", "/", 1) flg, ok = cmd.Flags[wordup] if !ok { return fmt.Errorf("flag '%s' not recognised", args[i]) lookup = strings.ToUpper(args[i]) } toggleValue = "false" fe, err := cmd.FlagTable.Find(lookup) if err != nil { return fmt.Errorf("flag '%s' not recognised", args[i]) } flg := fe.Flag if !flg.OptArg && assigned { return fmt.Errorf("flag '%s' is a toggle", args[i]) } Loading @@ -256,7 +247,11 @@ func (c Commands) run(words []string) error { flg.Value = strings.Trim(val, "\"'") } } else { flg.Value = toggleValue if fe.Negated { flg.Value = "false" } else { flg.Value = "true" } } } else { if len(cmd.Args) == cmd.MaxArgs { Loading @@ -270,3 +265,11 @@ func (c Commands) run(words []string) error { } return cmd.Action(cmd) } // ParseAndRun parses a command line and runs the command. // This is a backward-compatible wrapper that builds a CommandTable // and delegates to it. func (c Commands) ParseAndRun(line string) error { ct := BuildCommandTable(c) return ct.ParseAndRun(line) }
dclish/dclish_test.go 0 → 100644 +276 −0 Original line number Diff line number Diff line package dclish import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSplit(t *testing.T) { tests := []struct { name string input string expected []string }{ {"empty", "", []string{}}, {"single word", "READ", []string{"READ"}}, {"two words", "READ 5", []string{"READ", "5"}}, {"flag", "DIR/FOLDERS", []string{"DIR", "/FOLDERS"}}, {"flag with value", "ADD/SUBJECT=hello", []string{"ADD", "/SUBJECT=hello"}}, {"double quoted", `ADD "hello world"`, []string{"ADD", "hello world"}}, {"single quoted", "ADD 'hello world'", []string{"ADD", "hello world"}}, {"multiple flags", "DIR/FOLDERS/NEW", []string{"DIR", "/FOLDERS", "/NEW"}}, {"spaces everywhere", " READ 5 ", []string{"READ", "5"}}, {"flag with quoted value", `ADD/SUBJECT="hello world"`, []string{"ADD", `/SUBJECT="hello world"`}}, {"slash in value", "SET ACCESS/ALL", []string{"SET", "ACCESS", "/ALL"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := Split(tt.input) assert.Equal(t, tt.expected, result) }) } } func TestCommandTableFind(t *testing.T) { ct := CommandTable{ {Name: "ADD", Command: &Command{}}, {Name: "BACK", Command: &Command{}}, {Name: "DELETE", Command: &Command{}}, {Name: "DIRECTORY", Command: &Command{}}, {Name: "EXIT", Command: &Command{}}, } tests := []struct { name string prefix string wantName string wantErr bool errSubstr string }{ {"exact match", "ADD", "ADD", false, ""}, {"exact match case", "add", "ADD", false, ""}, {"unique prefix", "BA", "BACK", false, ""}, {"unique prefix EX", "EX", "EXIT", false, ""}, {"ambiguous", "D", "", true, "ambiguous"}, {"not found", "ZZZ", "", true, "unknown"}, {"exact DIR match", "DIRECTORY", "DIRECTORY", false, ""}, {"unique DEL prefix", "DEL", "DELETE", false, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { entry, err := ct.Find(tt.prefix) if tt.wantErr { require.Error(t, err) assert.Contains(t, err.Error(), tt.errSubstr) } else { require.NoError(t, err) assert.Equal(t, tt.wantName, entry.Name) } }) } } func TestFlagTableFind(t *testing.T) { flags := Flags{ "/ALL": {Description: "all"}, "/BELL": {Description: "bell"}, "/SUBJECT": {OptArg: true, Description: "subject"}, } ft := BuildFlagTable(flags) tests := []struct { name string prefix string wantName string negated bool wantErr bool errSubstr string }{ {"exact positive", "/ALL", "/ALL", false, false, ""}, {"exact negated", "/NOALL", "/NOALL", true, false, ""}, {"optarg no negation", "/SUBJECT", "/SUBJECT", false, false, ""}, {"optarg prefix", "/SU", "/SUBJECT", false, false, ""}, {"negated prefix", "/NOB", "/NOBELL", true, false, ""}, {"not found", "/ZZZ", "", false, true, "not recognised"}, {"case insensitive", "/all", "/ALL", false, false, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { entry, err := ft.Find(tt.prefix) if tt.wantErr { require.Error(t, err) assert.Contains(t, err.Error(), tt.errSubstr) } else { require.NoError(t, err) assert.Equal(t, tt.wantName, entry.Name) assert.Equal(t, tt.negated, entry.Negated) } }) } } func TestBuildFlagTable(t *testing.T) { flags := Flags{ "/FOO": {Description: "toggle foo"}, "/BAR": {OptArg: true, Description: "opt bar"}, "/VERBOSE": {Description: "verbose"}, } ft := BuildFlagTable(flags) // Toggle flags should have /NO entries. names := make([]string, len(ft)) for i, e := range ft { names[i] = e.Name } assert.Contains(t, names, "/FOO") assert.Contains(t, names, "/NOFOO") assert.Contains(t, names, "/VERBOSE") assert.Contains(t, names, "/NOVERBOSE") // OptArg should NOT have /NO entry. assert.Contains(t, names, "/BAR") assert.NotContains(t, names, "/NOBAR") } func TestCommandTableRun(t *testing.T) { var lastCmd string var lastArgs []string var lastFlags map[string]string action := func(cmd *Command) error { lastCmd = "called" lastArgs = cmd.Args lastFlags = map[string]string{} for k, v := range cmd.Flags { if v.Set { lastFlags[k] = v.Value } } return nil } commands := Commands{ "ADD": { Action: action, MaxArgs: 1, Flags: Flags{ "/SUBJECT": {OptArg: true}, "/BELL": {}, }, }, "SHOW": { Commands: Commands{ "VERSION": {Action: action}, "FOLDER": {Action: action, MaxArgs: 1}, }, }, } ct := BuildCommandTable(commands) t.Run("simple command", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("ADD myarg") require.NoError(t, err) assert.Equal(t, "called", lastCmd) assert.Equal(t, []string{"myarg"}, lastArgs) }) t.Run("command with flag", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("ADD/SUBJECT=hello") require.NoError(t, err) assert.Equal(t, "called", lastCmd) assert.Equal(t, "hello", lastFlags["/SUBJECT"]) }) t.Run("negated flag", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("ADD /NOBELL") require.NoError(t, err) assert.Equal(t, "called", lastCmd) assert.Equal(t, "false", lastFlags["/BELL"]) }) t.Run("prefix match", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("AD myarg") require.NoError(t, err) assert.Equal(t, "called", lastCmd) }) t.Run("subcommand", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("SHOW VERSION") require.NoError(t, err) assert.Equal(t, "called", lastCmd) }) t.Run("subcommand prefix", func(t *testing.T) { lastCmd = "" err := ct.ParseAndRun("SH VER") require.NoError(t, err) assert.Equal(t, "called", lastCmd) }) t.Run("too many args", func(t *testing.T) { err := ct.ParseAndRun("ADD arg1 arg2") require.Error(t, err) assert.Contains(t, err.Error(), "too many args") }) t.Run("unknown command", func(t *testing.T) { err := ct.ParseAndRun("ZZZZ") require.Error(t, err) assert.Contains(t, err.Error(), "unknown") }) } func TestCompletions(t *testing.T) { ct := CommandTable{ {Name: "ADD", Command: &Command{}}, {Name: "BACK", Command: &Command{}}, {Name: "DELETE", Command: &Command{}}, {Name: "DIRECTORY", Command: &Command{}}, } t.Run("prefix D", func(t *testing.T) { result := ct.Completions("D") assert.Equal(t, []string{"DELETE", "DIRECTORY"}, result) }) t.Run("prefix A", func(t *testing.T) { result := ct.Completions("A") assert.Equal(t, []string{"ADD"}, result) }) t.Run("prefix Z", func(t *testing.T) { result := ct.Completions("Z") assert.Empty(t, result) }) t.Run("empty prefix", func(t *testing.T) { result := ct.Completions("") assert.Len(t, result, 4) }) } func TestFlagTableCompletions(t *testing.T) { flags := Flags{ "/ALL": {}, "/BELL": {}, "/SUBJECT": {OptArg: true}, } ft := BuildFlagTable(flags) t.Run("prefix /A", func(t *testing.T) { result := ft.Completions("/A") assert.Equal(t, []string{"/ALL"}, result) }) t.Run("prefix /NO", func(t *testing.T) { result := ft.Completions("/NO") assert.Contains(t, result, "/NOALL") assert.Contains(t, result, "/NOBELL") }) }
dclish/table.go 0 → 100644 +98 −0 Original line number Diff line number Diff line package dclish import ( "fmt" "sort" "strings" ) // CommandEntry is an entry in a CommandTable. type CommandEntry struct { Name string Command *Command } // CommandTable is a sorted slice of CommandEntry for binary search lookup. type CommandTable []CommandEntry // FlagEntry is an entry in a FlagTable. type FlagEntry struct { Name string Flag *Flag Negated bool } // FlagTable is a sorted slice of FlagEntry for binary search lookup. type FlagTable []FlagEntry // Find locates a command by exact match or unique prefix. func (ct CommandTable) Find(prefix string) (CommandEntry, error) { prefix = strings.ToUpper(prefix) i := sort.Search(len(ct), func(i int) bool { return ct[i].Name >= prefix }) if i < len(ct) && ct[i].Name == prefix { return ct[i], nil } if i >= len(ct) || !strings.HasPrefix(ct[i].Name, prefix) { return CommandEntry{}, fmt.Errorf("unknown command '%s'", prefix) } if i+1 < len(ct) && strings.HasPrefix(ct[i+1].Name, prefix) { possibles := []string{} for j := i; j < len(ct) && strings.HasPrefix(ct[j].Name, prefix); j++ { possibles = append(possibles, ct[j].Name) } return CommandEntry{}, fmt.Errorf("ambiguous command '%s' (matches %s)", prefix, strings.Join(possibles, ", ")) } return ct[i], nil } // Completions returns all command names matching the given prefix. func (ct CommandTable) Completions(prefix string) []string { prefix = strings.ToUpper(prefix) i := sort.Search(len(ct), func(i int) bool { return ct[i].Name >= prefix }) var result []string for j := i; j < len(ct) && strings.HasPrefix(ct[j].Name, prefix); j++ { result = append(result, ct[j].Name) } return result } // Find locates a flag by exact match or unique prefix. func (ft FlagTable) Find(prefix string) (FlagEntry, error) { prefix = strings.ToUpper(prefix) i := sort.Search(len(ft), func(i int) bool { return ft[i].Name >= prefix }) if i < len(ft) && ft[i].Name == prefix { return ft[i], nil } if i >= len(ft) || !strings.HasPrefix(ft[i].Name, prefix) { return FlagEntry{}, fmt.Errorf("flag '%s' not recognised", prefix) } if i+1 < len(ft) && strings.HasPrefix(ft[i+1].Name, prefix) { possibles := []string{} for j := i; j < len(ft) && strings.HasPrefix(ft[j].Name, prefix); j++ { possibles = append(possibles, ft[j].Name) } return FlagEntry{}, fmt.Errorf("ambiguous flag '%s' (matches %s)", prefix, strings.Join(possibles, ", ")) } return ft[i], nil } // Completions returns all flag names matching the given prefix. func (ft FlagTable) Completions(prefix string) []string { prefix = strings.ToUpper(prefix) i := sort.Search(len(ft), func(i int) bool { return ft[i].Name >= prefix }) var result []string for j := i; j < len(ft) && strings.HasPrefix(ft[j].Name, prefix); j++ { result = append(result, ft[j].Name) } return result }