Commit fad1f1ea authored by Kevin Lyda's avatar Kevin Lyda
Browse files

Add mail subsystem

Clean up command processing.
parent dbee0389
Loading
Loading
Loading
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
}
+41 −34
Original line number Diff line number Diff line
package dclish

import (
	"sort"
	"strings"
	"unicode"
)
@@ -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,
@@ -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 {
@@ -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
}
+54 −51
Original line number Diff line number Diff line
@@ -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
@@ -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"
@@ -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
@@ -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])
			}
@@ -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 {
@@ -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