diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1521716..685bf27 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: '1.21' + go-version: '1.25' - name: Build run: go build -v ./... @@ -42,7 +42,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: '1.21' + go-version: '1.25' - name: Build run: go build -v ./... diff --git a/.gitignore b/.gitignore index 1f086c4..e1603b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ console.wiki .gemini +.claude diff --git a/command.go b/command.go index d3858cd..10e0a89 100644 --- a/command.go +++ b/command.go @@ -20,8 +20,8 @@ type Commands func() *cobra.Command // SetCommands requires a function returning a tree of cobra commands to be used. func (m *Menu) SetCommands(cmds Commands) { - m.mutex.RLock() - defer m.mutex.RUnlock() + m.mutex.Lock() + defer m.mutex.Unlock() m.cmds = cmds } @@ -31,6 +31,9 @@ func (m *Menu) SetCommands(cmds Commands) { // If "windows" is used as the argument here, all windows commands for the current // menu are subsequently hidden, until ShowCommands("windows") is called. func (c *Console) HideCommands(filters ...string) { + c.mutex.Lock() + defer c.mutex.Unlock() + next: for _, filt := range filters { for _, filter := range c.filters { @@ -50,8 +53,8 @@ next: // Use this function if you have previously called HideCommands("filter") and want // these commands to be available back under their respective menu. func (c *Console) ShowCommands(filters ...string) { - c.mutex.RLock() - defer c.mutex.RUnlock() + c.mutex.Lock() + defer c.mutex.Unlock() updated := make([]string, 0) diff --git a/completer.go b/completer.go index bf33b1a..c60604d 100644 --- a/completer.go +++ b/completer.go @@ -81,10 +81,12 @@ func (c *Console) complete(input []rune, pos int) readline.Completions { comps = comps.Prefix(prefixComp) comps.PREFIX = prefixLine - // Finally, reset our command tree for the next call. + // Finally, reset our command tree for the next call. Only the commands need + // regenerating here: the prompt is already bound and no command output was + // produced, so the full resetPreRun would just be wasted work per keystroke. + // (resetCommands already re-hides filtered commands.) completer.ClearStorage() - menu.resetPreRun() - menu.hideFilteredCommands(menu.Command) + menu.resetCommands() return comps } @@ -115,6 +117,21 @@ func (c *Console) justifyCommandComps(comps readline.Completions) readline.Compl // highlightSyntax - Entrypoint to all input syntax highlighting in the Wiregost console. func (c *Console) highlightSyntax(input []rune) string { + // Serve a memoized result when the input has not changed since the last + // render. The cache is cleared whenever the command tree is regenerated, + // so a stale tree can never produce a stale highlight. + key := string(input) + if cached := c.hlCache.Load(); cached != nil && cached.input == key { + return cached.output + } + + highlighted := c.computeHighlight(input) + c.hlCache.Store(&highlightCache{input: key, output: highlighted}) + + return highlighted +} + +func (c *Console) computeHighlight(input []rune) string { // Split the line as shellwords args, unprocessed, err := line.Split(string(input), true) if err != nil { diff --git a/concurrency_test.go b/concurrency_test.go new file mode 100644 index 0000000..6920110 --- /dev/null +++ b/concurrency_test.go @@ -0,0 +1,68 @@ +package console + +import ( + "errors" + "sync" + "testing" + + "github.com/spf13/cobra" +) + +// TestConcurrentStateAccess stresses the console's shared state (filters, the +// menus map, and per-menu interrupt handlers) from many goroutines at once. +// +// It is meant to be run with the race detector (`go test -race`). Before the +// locking fixes, these paths mutated maps/slices under a read lock (or no lock +// at all), which the detector flags and which can panic on concurrent map +// writes in production. +func TestConcurrentStateAccess(t *testing.T) { + c := New("test") + + // Give the active menu a small command tree so that ActiveFiltersFor has + // something to recurse over while filters are being mutated concurrently. + menu := c.ActiveMenu() + menu.SetCommands(func() *cobra.Command { + root := &cobra.Command{Use: "root"} + child := &cobra.Command{ + Use: "child", + Annotations: map[string]string{CommandFilterKey: "filterA,filterB"}, + Run: func(*cobra.Command, []string) {}, + } + root.AddCommand(child) + return root + }) + menu.resetPreRun() + + errInt := errors.New("interrupt") + + const workers = 64 + + var wg sync.WaitGroup + wg.Add(workers) + + for i := 0; i < workers; i++ { + go func(i int) { + defer wg.Done() + + // Filters: concurrent writers (Hide/Show) and readers (ActiveFiltersFor). + c.HideCommands("filterA", "filterB") + c.ShowCommands("filterA") + + m := c.ActiveMenu() + for _, cmd := range m.Command.Commands() { + _ = m.ActiveFiltersFor(cmd) + } + + // Menus map: concurrent creation and lookup. + _ = c.NewMenu("menu") + _ = c.Menu("menu") + _ = c.ActiveMenu() + + // Interrupt handlers map: concurrent writers. + m.AddInterrupt(errInt, func(*Console) {}) + m.DelInterrupt(errInt) + }(i) + } + + wg.Wait() +} diff --git a/console.go b/console.go index a8c335f..a3197b1 100644 --- a/console.go +++ b/console.go @@ -2,8 +2,10 @@ package console import ( "fmt" + "os" "strings" "sync" + "sync/atomic" "github.com/reeflective/readline" @@ -12,6 +14,12 @@ import ( "github.com/reeflective/readline/inputrc" ) +// highlightCache holds a memoized syntax-highlighting result for one input. +type highlightCache struct { + input string + output string +} + // Console is an integrated console application instance. type Console struct { // Application @@ -21,14 +29,24 @@ type Console struct { cmdHighlight string // Ansi code for highlighting of command in default highlighter. Green by default. flagHighlight string // Ansi code for highlighting of flag in default highlighter. Grey by default. menus map[string]*Menu // Different command trees, prompt engines, etc. + current *Menu // Cached pointer to the active menu (guarded by mutex). filters []string // Hide commands based on their attributes and current context. - isExecuting bool // Used by log functions, which need to adapt behavior (print the prompt, etc.) + isExecuting atomic.Bool // Used by log functions, which need to adapt behavior (print the prompt, etc.) printed bool // Used to adjust asynchronous messages too. mutex *sync.RWMutex // Concurrency management. + // hlCache memoizes the last syntax-highlighting result. The highlighter is + // called on every render (even when only the cursor moved), so caching the + // output for an unchanged input avoids re-splitting and re-walking the + // command tree. It is invalidated whenever the command tree is regenerated + // (see Menu.regenerate), so the input alone is a sufficient key. + hlCache atomic.Pointer[highlightCache] + // Execution // Leave an empty line before executing the command. + // This is the console-wide default; a menu may override it with + // Menu.SetNewlineBefore. NewlineBefore bool // Leave an empty line after executing the command. @@ -36,19 +54,31 @@ type Console struct { // with TransientPrintf(), Printf() calls, you should leave this to false, // and add a leading newline to your prompt instead: the readline shell will // know how to handle it in all situations. + // This is the console-wide default; a menu may override it with + // Menu.SetNewlineAfter. NewlineAfter bool // Leave empty lines with NewlineBefore and NewlineAfter, even if the provided input was empty. // Empty characters are defined as any number of spaces and tabs. The 'empty' character set // can be changed by modifying Console.EmptyChars // This field is false by default. + // This is the console-wide default; a menu may override it with + // Menu.SetNewlineWhenEmpty. NewlineWhenEmpty bool // Characters that are used to determine whether an input line was empty. If a line is not entirely // made up by any of these characters, then it is not considered empty. The default characters // are ' ' and '\t'. + // This is the console-wide default; a menu may override it with + // Menu.SetEmptyChars. EmptyChars []rune + // Signals is the set of OS signals the console traps while a command is + // running. When one is received, the running command's context is + // cancelled (see StartContext for the cancellation model). If empty, the + // console defaults to SIGINT, SIGTERM and SIGQUIT. + Signals []os.Signal + // PreReadlineHooks - All the functions in this list will be executed, // in their respective orders, before the console starts reading // any user input (ie, before redrawing the prompt). @@ -91,6 +121,7 @@ func New(app string) *Console { // Each menu is created with a default prompt engine. defaultMenu := console.NewMenu("") defaultMenu.active = true + console.current = defaultMenu // Set the history for this menu for _, name := range defaultMenu.historyNames { @@ -109,6 +140,7 @@ func New(app string) *Console { // Defaults console.EmptyChars = []rune{' ', '\t'} + console.Signals = append([]os.Signal(nil), defaultTrapSignals...) return console } @@ -154,8 +186,8 @@ func (c *Console) SetDefaultFlagHighlight(seq string) { // well as some specific items like history sources, prompt // configurations, sets of expanded variables, and others. func (c *Console) NewMenu(name string) *Menu { - c.mutex.RLock() - defer c.mutex.RUnlock() + c.mutex.Lock() + defer c.mutex.Unlock() menu := newMenu(name, c) c.menus[name] = menu @@ -164,16 +196,13 @@ func (c *Console) NewMenu(name string) *Menu { // ActiveMenu - Return the currently used console menu. func (c *Console) ActiveMenu() *Menu { - c.mutex.Lock() - defer c.mutex.Unlock() - return c.activeMenu() } // Menu returns one of the console menus by name, or nil if no menu is found. func (c *Console) Menu(name string) *Menu { - c.mutex.Lock() - defer c.mutex.Unlock() + c.mutex.RLock() + defer c.mutex.RUnlock() return c.menus[name] } @@ -184,33 +213,39 @@ func (c *Console) Menu(name string) *Menu { // are bound to this menu name, the current menu is kept. func (c *Console) SwitchMenu(menu string) { c.mutex.Lock() + target, found := c.menus[menu] - c.mutex.Unlock() + current := c.current - if found && target != nil { - // Only switch if the target menu was found. - current := c.activeMenu() - if current != nil && target == current { - return - } + // Only switch if the target menu was found and is not already current. + if !found || target == nil || target == current { + c.mutex.Unlock() + return + } - if current != nil { - current.active = false - } + if current != nil { + current.active = false + } - target.active = true + target.active = true + c.current = target - // Remove the currently bound history sources - // (old menu) and bind the ones peculiar to this one. - c.shell.History.Delete() + c.mutex.Unlock() - for _, name := range target.historyNames { - c.shell.History.Add(name, target.histories[name]) - } + // The following touches the shell and regenerates the menu commands, + // which itself reacquires c.mutex (history/filters): it must run with + // the lock released to avoid a self-deadlock. - // Regenerate the commands, outputs and everything related. - target.resetPreRun() + // Remove the currently bound history sources + // (old menu) and bind the ones peculiar to this one. + c.shell.History.Delete() + + for _, name := range target.historyNames { + c.shell.History.Add(name, target.histories[name]) } + + // Regenerate the commands, outputs and everything related. + target.resetPreRun() } // @@ -224,18 +259,20 @@ func (c *Console) SwitchMenu(menu string) { // If this function is called while a command is running, the console will simply print the log // below the line, and will not print the prompt. In any other case this function works normally. func (c *Console) TransientPrintf(msg string, args ...any) (n int, err error) { - if c.isExecuting { + if c.isExecuting.Load() { return fmt.Printf(msg, args...) } + newlineAfter := c.activeMenu().newlineAfter() + // If the last message we printed asynchronously // immediately precedes this new message, move up // another row, so we don't waste too much space. - if c.printed && c.NewlineAfter { + if c.printed && newlineAfter { fmt.Print("\x1b[1A") } - if c.NewlineAfter { + if newlineAfter { msg += "\n" } @@ -250,7 +287,7 @@ func (c *Console) TransientPrintf(msg string, args ...any) (n int, err error) { // If this function is called while a command is running, the console will simply print the log // below the line, and will not print the prompt. In any other case this function works normally. func (c *Console) Printf(msg string, args ...any) (n int, err error) { - if c.isExecuting { + if c.isExecuting.Load() { return fmt.Printf(msg, args...) } @@ -294,10 +331,11 @@ func (c *Console) setupShell() { } func (c *Console) activeMenu() *Menu { - for _, menu := range c.menus { - if menu.active { - return menu - } + c.mutex.RLock() + defer c.mutex.RUnlock() + + if c.current != nil { + return c.current } // Else return the default menu. diff --git a/example/feature-commands.go b/example/feature-commands.go new file mode 100644 index 0000000..16bcd30 --- /dev/null +++ b/example/feature-commands.go @@ -0,0 +1,213 @@ +package main + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/carapace-sh/carapace" + "github.com/spf13/cobra" + + "github.com/reeflective/console" +) + +// featureGroupID groups the commands that demonstrate the readline hint and +// async-completion features. +const featureGroupID = "readline" + +// setupReadlineHints registers a passive hint provider on the shell. The +// provider is recomputed from the current input line on every refresh and its +// result is shown below the input, in the dedicated "provided" hint lane. +// +// Here it resolves the command being typed and shows its short description. +// Because this lane is independent from completion hints (set by the completion +// engine) and from transient/async status messages (see the `notify`/`hint` +// commands), all three can be displayed at once without clobbering each other. +func setupReadlineHints(app *console.Console) { + dim := func(format string, args ...any) []rune { + return []rune("\x1b[2;3m" + fmt.Sprintf(format, args...) + "\x1b[0m") + } + + app.Shell().Hint.SetProvider(func(line []rune, _ int) []rune { + fields := strings.Fields(string(line)) + if len(fields) == 0 { + return dim("type a command — try 'notify', 'hint set ...', or 'scan '") + } + + menu := app.ActiveMenu() + if menu == nil || menu.Command == nil { + return nil + } + + // Find resolves the deepest command matched by the words typed so far. + cmd, _, err := menu.Find(fields) + if err != nil || cmd == nil || cmd == menu.Command { + return nil + } + + return dim("%s — %s", cmd.CommandPath(), cmd.Short) + }) +} + +// readlineFeatureCommands builds the commands demonstrating the hint lanes and +// async completion regeneration. They are added to the main menu. +func readlineFeatureCommands(app *console.Console) []*cobra.Command { + return []*cobra.Command{ + notifyCommand(app), + hintCommand(app), + scanCommand(app), + } +} + +// notifyCommand demonstrates ASYNC status updates in the transient hint lane. +// It starts a background job that pushes status messages from another goroutine +// with Hint.SetTransient; the shell repaints on its own (no keystroke), thanks +// to the async-refresh wake. +func notifyCommand(app *console.Console) *cobra.Command { + return &cobra.Command{ + Use: "notify", + Short: "Async status updates shown in the hint lane (transient hint + wake)", + GroupID: featureGroupID, + Run: func(_ *cobra.Command, _ []string) { + hint := app.Shell().Hint + stages := []string{ + "\x1b[33m⠋ connecting…\x1b[0m", + "\x1b[33m⠙ authenticating…\x1b[0m", + "\x1b[33m⠹ transferring…\x1b[0m", + "\x1b[32m✓ transfer complete\x1b[0m", + } + + go func() { + for _, stage := range stages { + time.Sleep(1200 * time.Millisecond) + hint.SetTransient(stage) + } + + time.Sleep(1500 * time.Millisecond) + hint.ClearTransient() + }() + + fmt.Println("Background job started — watch the hint line below the prompt update on its own (no keystroke needed).") + }, + } +} + +// hintCommand demonstrates SYNCHRONOUS use of the transient hint lane: setting a +// sticky status message that persists across keystrokes (unlike a completion +// hint) until it is cleared or replaced. +func hintCommand(app *console.Console) *cobra.Command { + hint := &cobra.Command{ + Use: "hint", + Short: "Set or clear a sticky transient hint immediately (non-async)", + GroupID: featureGroupID, + } + + hint.AddCommand(&cobra.Command{ + Use: "set MESSAGE...", + Short: "Set the transient hint lane to a message (persists until cleared)", + Args: cobra.MinimumNArgs(1), + Run: func(_ *cobra.Command, args []string) { + app.Shell().Hint.SetTransient("\x1b[36m" + strings.Join(args, " ") + "\x1b[0m") + }, + }) + + hint.AddCommand(&cobra.Command{ + Use: "clear", + Short: "Clear the transient hint lane", + Run: func(_ *cobra.Command, _ []string) { + app.Shell().Hint.ClearTransient() + }, + }) + + return hint +} + +// hostDiscovery is a process-wide singleton: the console rebuilds its command +// tree (and thus re-runs scanCommand) on each completion, so the discovery state +// must persist across those rebuilds rather than being recreated each time. +// +// Seeded with two known hosts so the menu opens and stays open — a single +// candidate would be auto-accepted, closing the menu before any async result +// could be shown. +var hostDiscovery = &discovery{base: []string{"localhost", "gateway"}} + +// scanCommand demonstrates ASYNC completions. Its argument completer returns a +// set of hosts that a background "discovery" grows over time; each time a host +// is found, the goroutine calls Shell().RefreshCompletions(), which rebuilds the +// already-open completion menu in place — so hosts appear live while the menu +// stays open, with no keystroke from the user. +func scanCommand(app *console.Console) *cobra.Command { + scan := &cobra.Command{ + Use: "scan [HOST]", + Short: "Async completions — press Tab after 'scan ' and watch hosts appear live", + GroupID: featureGroupID, + Args: cobra.MaximumNArgs(1), + Run: func(_ *cobra.Command, args []string) { + if len(args) == 0 { + fmt.Println("Usage: scan HOST (press Tab after 'scan ' and watch the menu fill in)") + return + } + + fmt.Println("Scanning host:", args[0]) + }, + } + + carapace.Gen(scan).PositionalCompletion( + carapace.ActionCallback(func(_ carapace.Context) carapace.Action { + hostDiscovery.start(app) + return carapace.ActionValues(hostDiscovery.snapshot()...) + }), + ) + + return scan +} + +// discovery simulates an asynchronous completion producer: a background routine +// appends "discovered" hosts to a cache and asks the shell to regenerate the +// open menu in place. +type discovery struct { + mu sync.Mutex + base []string + found []string + running bool +} + +// snapshot returns the current known + discovered hosts. +func (d *discovery) snapshot() []string { + d.mu.Lock() + defer d.mu.Unlock() + + return append(append([]string{}, d.base...), d.found...) +} + +// start kicks off one discovery run if none is in progress. Each newly found +// host triggers an in-place regeneration of the open completion menu. +func (d *discovery) start(app *console.Console) { + d.mu.Lock() + if d.running { + d.mu.Unlock() + return + } + + d.running = true + d.found = nil + d.mu.Unlock() + + go func() { + for i := 1; i <= 8; i++ { + time.Sleep(900 * time.Millisecond) + + d.mu.Lock() + d.found = append(d.found, fmt.Sprintf("10.0.0.%d", i)) + d.mu.Unlock() + + // Rebuild the open menu in place with the newly discovered host. + app.Shell().RefreshCompletions() + } + + d.mu.Lock() + d.running = false + d.mu.Unlock() + }() +} diff --git a/example/main-commands.go b/example/main-commands.go index b51f137..71ac7a6 100644 --- a/example/main-commands.go +++ b/example/main-commands.go @@ -27,6 +27,7 @@ func mainMenuCommands(app *console.Console) console.Commands { &cobra.Group{ID: "filesystem", Title: "filesystem"}, &cobra.Group{ID: "deployment", Title: "deployment"}, &cobra.Group{ID: "tools", Title: "tools"}, + &cobra.Group{ID: featureGroupID, Title: "readline features"}, ) // Readline subcommands @@ -608,6 +609,13 @@ func mainMenuCommands(app *console.Console) console.Commands { c.FlagCompletion(flagMap) } + // Add the readline-feature demo commands AFTER the generic completion + // loop above, so their custom (e.g. async) completers are not replaced + // by the default file completion applied to every command with args. + for _, cmd := range readlineFeatureCommands(app) { + rootCmd.AddCommand(cmd) + } + rootCmd.SetHelpCommandGroupID("core") rootCmd.InitDefaultHelpCmd() rootCmd.CompletionOptions.DisableDefaultCmd = true diff --git a/example/main.go b/example/main.go index 12f6061..9f9db44 100644 --- a/example/main.go +++ b/example/main.go @@ -41,6 +41,10 @@ func main() { // Set some custom prompt handlers for this menu. setupPrompt(menu) + // Register a passive hint provider on the shell, demonstrating the readline + // hint lanes (passive provider / async transient / completion hints). + setupReadlineHints(app) + // All menus currently each have a distinct, in-memory history source. // Replace the main (current) menu's history with one writing to our // application history file. The default history is named after its menu. diff --git a/features_test.go b/features_test.go new file mode 100644 index 0000000..2eb8a61 --- /dev/null +++ b/features_test.go @@ -0,0 +1,124 @@ +package console + +import ( + "errors" + "fmt" + "io" + "testing" +) + +func TestHandleInterruptMatching(t *testing.T) { + c := New("test") + m := c.ActiveMenu() + + var fired []string + sentinel := errors.New("boom") + + m.AddInterrupt(sentinel, func(*Console) { fired = append(fired, "sentinel") }) + m.AddInterrupt(io.EOF, func(*Console) { fired = append(fired, "eof") }) + + // errors.Is match: a wrapped io.EOF should reach the io.EOF handler. + fired = nil + m.handleInterrupt(fmt.Errorf("read failed: %w", io.EOF)) + if !reflect_equal(fired, []string{"eof"}) { + t.Fatalf("wrapped io.EOF fired %v, want [eof]", fired) + } + + // String fallback: a distinct error value with the same message as the + // registered sentinel should still match (the historical pattern). + fired = nil + m.handleInterrupt(errors.New("boom")) + if !reflect_equal(fired, []string{"sentinel"}) { + t.Fatalf("same-message error fired %v, want [sentinel]", fired) + } + + // No match: nothing fires. + fired = nil + m.handleInterrupt(errors.New("unrelated")) + if len(fired) != 0 { + t.Fatalf("unrelated error fired %v, want none", fired) + } +} + +func TestMenuNewlineOverrides(t *testing.T) { + c := New("test") + c.NewlineAfter = true + c.NewlineBefore = false + c.NewlineWhenEmpty = false + m := c.ActiveMenu() + + // With no override, the menu inherits the console defaults. + if !m.newlineAfter() { + t.Fatal("newlineAfter: expected inherited true") + } + if m.newlineBefore() { + t.Fatal("newlineBefore: expected inherited false") + } + if m.newlineWhenEmpty() { + t.Fatal("newlineWhenEmpty: expected inherited false") + } + + // Overrides take precedence over the console default. + m.SetNewlineAfter(false) + m.SetNewlineBefore(true) + m.SetNewlineWhenEmpty(true) + + if m.newlineAfter() { + t.Fatal("newlineAfter: expected override false") + } + if !m.newlineBefore() { + t.Fatal("newlineBefore: expected override true") + } + if !m.newlineWhenEmpty() { + t.Fatal("newlineWhenEmpty: expected override true") + } + + // Changing the console default no longer affects an overridden menu. + c.NewlineAfter = true + if m.newlineAfter() { + t.Fatal("newlineAfter: override should shadow console default") + } +} + +func TestMenuEmptyCharsOverride(t *testing.T) { + c := New("test") + m := c.ActiveMenu() + + // Inherits the console default set. + if string(m.emptyCharSet()) != string(c.EmptyChars) { + t.Fatalf("emptyCharSet inherited = %q, want %q", string(m.emptyCharSet()), string(c.EmptyChars)) + } + + // Override. + m.SetEmptyChars('x', 'y') + if string(m.emptyCharSet()) != "xy" { + t.Fatalf("emptyCharSet override = %q, want %q", string(m.emptyCharSet()), "xy") + } + + // No arguments clears the override, restoring inheritance. + m.SetEmptyChars() + if string(m.emptyCharSet()) != string(c.EmptyChars) { + t.Fatalf("emptyCharSet after clear = %q, want %q", string(m.emptyCharSet()), string(c.EmptyChars)) + } +} + +func TestConsoleDefaultSignals(t *testing.T) { + c := New("test") + if len(c.Signals) != len(defaultTrapSignals) { + t.Fatalf("default Signals = %v, want %v", c.Signals, defaultTrapSignals) + } +} + +// reflect_equal is a tiny string-slice comparison helper to avoid importing +// reflect for a single use. +func reflect_equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/filters_test.go b/filters_test.go new file mode 100644 index 0000000..2155bed --- /dev/null +++ b/filters_test.go @@ -0,0 +1,84 @@ +package console + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +// buildFilterTree returns a small command tree: +// +// root +// └── net (filter: "windows") +// └── scan (no annotations -> inherits from parent) +// +// plus a standalone, unannotated "free" command. +func buildFilterTree() (net, scan, free *cobra.Command) { + root := &cobra.Command{Use: "root"} + net = &cobra.Command{Use: "net", Annotations: map[string]string{CommandFilterKey: "windows"}} + scan = &cobra.Command{Use: "scan"} + free = &cobra.Command{Use: "free"} + + root.AddCommand(net, free) + net.AddCommand(scan) + + return net, scan, free +} + +func TestActiveFiltersFor(t *testing.T) { + c := New("test") + menu := c.ActiveMenu() + net, scan, free := buildFilterTree() + + // No filter active yet: nothing is filtered, even annotated commands. + if got := menu.ActiveFiltersFor(net); len(got) != 0 { + t.Fatalf("before HideCommands: ActiveFiltersFor(net) = %q, want none", got) + } + + // Activate the "windows" filter. + c.HideCommands("windows") + + if got := menu.ActiveFiltersFor(net); !reflect.DeepEqual(got, []string{"windows"}) { + t.Fatalf("ActiveFiltersFor(net) = %q, want [windows]", got) + } + + // A child with no annotations inherits its parent's active filters. + if got := menu.ActiveFiltersFor(scan); !reflect.DeepEqual(got, []string{"windows"}) { + t.Fatalf("ActiveFiltersFor(scan) = %q, want [windows] (inherited)", got) + } + + // An unrelated, unannotated command is never filtered. + if got := menu.ActiveFiltersFor(free); len(got) != 0 { + t.Fatalf("ActiveFiltersFor(free) = %q, want none", got) + } + + // Removing the filter restores availability. + c.ShowCommands("windows") + if got := menu.ActiveFiltersFor(net); len(got) != 0 { + t.Fatalf("after ShowCommands: ActiveFiltersFor(net) = %q, want none", got) + } +} + +func TestCheckIsAvailable(t *testing.T) { + c := New("test") + menu := c.ActiveMenu() + net, scan, free := buildFilterTree() + + // A nil command is always available. + if err := menu.CheckIsAvailable(nil); err != nil { + t.Fatalf("CheckIsAvailable(nil) = %v, want nil", err) + } + + c.HideCommands("windows") + + if err := menu.CheckIsAvailable(net); err == nil { + t.Fatal("CheckIsAvailable(net) = nil, want error (command is filtered)") + } + if err := menu.CheckIsAvailable(scan); err == nil { + t.Fatal("CheckIsAvailable(scan) = nil, want error (inherited filter)") + } + if err := menu.CheckIsAvailable(free); err != nil { + t.Fatalf("CheckIsAvailable(free) = %v, want nil (not filtered)", err) + } +} diff --git a/go.mod b/go.mod index becf4e0..020899e 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,22 @@ module github.com/reeflective/console -go 1.24.0 +go 1.25.0 require ( - github.com/carapace-sh/carapace v1.7.1 + github.com/carapace-sh/carapace v1.11.6 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/reeflective/readline v1.1.4 - github.com/spf13/cobra v1.8.1 - github.com/spf13/pflag v1.0.6 - golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac - mvdan.cc/sh/v3 v3.7.0 + github.com/reeflective/readline v1.2.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 + mvdan.cc/sh/v3 v3.13.1 ) require ( - github.com/carapace-sh/carapace-shlex v1.0.1 // indirect + github.com/carapace-sh/carapace-shlex v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.45.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 63fb960..9d429a5 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,16 @@ -github.com/carapace-sh/carapace v1.7.1 h1:GjMjPNEMHhTstneZD2M3Ypjb+lW5YNEV1AfYmRhsG4c= -github.com/carapace-sh/carapace v1.7.1/go.mod h1:fHdo3nEFe1QnIXxeA/Z1O9dCI83sfCsKfxrogpHfgtM= -github.com/carapace-sh/carapace-shlex v1.0.1 h1:ww0JCgWpOVuqWG7k3724pJ18Lq8gh5pHQs9j3ojUs1c= -github.com/carapace-sh/carapace-shlex v1.0.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= -github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/carapace-sh/carapace v1.11.6 h1:fUZv+oAMgbiDEpNPNis4n35tzqE3h8yshOohLJ2Mz4Y= +github.com/carapace-sh/carapace v1.11.6/go.mod h1:5MUSHyLN9GGb5/NY/j9VI68/TcZV4ApRCAHGg4WeU0s= +github.com/carapace-sh/carapace-shlex v1.1.1 h1:ccmNeetAYZOk4IcV36youFDsXusT9uCNW2Njkw+QS+Q= +github.com/carapace-sh/carapace-shlex v1.1.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -18,30 +22,27 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/reeflective/readline v1.1.3 h1:meGkuEmujZHmalJ9eT3pYkwtkufH5EwYFPTnaph0T0s= -github.com/reeflective/readline v1.1.3/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY= -github.com/reeflective/readline v1.1.4 h1:HEdVYiPZ7e2CrP3uU/l6wApQdpkY0MjR8lINNboVtFk= -github.com/reeflective/readline v1.1.4/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY= +github.com/reeflective/readline v1.2.0 h1:QuT4CbHTFnZfQF1aQpxGDTfmGUPmbr/7r+oS9JLbsKA= +github.com/reeflective/readline v1.2.0/go.mod h1:bOpqx2/VqGlIoobyWR1Vgt/p5FiMfIHj4OicPuw6RfU= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97 h1:3RPlVWzZ/PDqmVuf/FKHARG5EMid/tl7cv54Sw/QRVY= -github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= -golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM= +golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg= -mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/highlight_cache_test.go b/highlight_cache_test.go new file mode 100644 index 0000000..6e07393 --- /dev/null +++ b/highlight_cache_test.go @@ -0,0 +1,40 @@ +package console + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestHighlightCacheInvalidation(t *testing.T) { + c := New("test") + menu := c.ActiveMenu() + menu.SetCommands(func() *cobra.Command { + root := &cobra.Command{Use: "root"} + root.AddCommand(&cobra.Command{Use: "net", Run: func(*cobra.Command, []string) {}}) + return root + }) + menu.resetPreRun() + + in := []rune("net") + first := c.highlightSyntax(in) + + cached := c.hlCache.Load() + if cached == nil || cached.input != "net" { + t.Fatalf("expected cache populated for %q, got %+v", "net", cached) + } + if cached.output != first { + t.Fatalf("cached output %q != returned %q", cached.output, first) + } + + // Same input is served from cache and yields the same result. + if second := c.highlightSyntax(in); second != first { + t.Fatalf("second highlight %q != first %q", second, first) + } + + // Regenerating the command tree invalidates the cache. + menu.resetPreRun() + if c.hlCache.Load() != nil { + t.Fatal("expected highlight cache cleared after resetPreRun") + } +} diff --git a/internal/completion/line.go b/internal/completion/line.go index 45d3ec4..5da8f32 100644 --- a/internal/completion/line.go +++ b/internal/completion/line.go @@ -11,19 +11,6 @@ import ( "github.com/reeflective/console/internal/line" ) -// when the completer has returned us some completions, we sometimes -// needed to post-process them a little before passing them to our shell. -func UnescapeValue(prefixComp, prefixLine, val string) string { - quoted := strings.HasPrefix(prefixLine, "\"") || - strings.HasPrefix(prefixLine, "'") - - if quoted { - val = strings.ReplaceAll(val, "\\ ", " ") - } - - return val -} - // SplitArgs splits the line in valid words, prepares them in various ways before calling // the completer with them, and also determines which parts of them should be used as // prefixes, in the completions and/or in the line. diff --git a/internal/completion/line_test.go b/internal/completion/line_test.go new file mode 100644 index 0000000..090e4c1 --- /dev/null +++ b/internal/completion/line_test.go @@ -0,0 +1,109 @@ +package completion + +import ( + "reflect" + "testing" + + "github.com/reeflective/console/internal/line" +) + +func TestSplitCompWords(t *testing.T) { + tests := []struct { + name string + input string + wantWords []string + wantRemainder string + wantErr error + }{ + {"empty", "", []string{}, "", nil}, + {"two words", "echo hello", []string{"echo", "hello"}, "", nil}, + {"single quoted", "echo 'hello world'", []string{"echo", "hello world"}, "", nil}, + {"double quoted", `echo "hello world"`, []string{"echo", "hello world"}, "", nil}, + {"unterminated single", "echo 'foo", []string{"echo"}, "foo", line.ErrUnterminatedSingleQuote}, + {"unterminated double", `echo "foo`, []string{"echo"}, "foo", line.ErrUnterminatedDoubleQuote}, + {"trailing backslash", `echo foo\`, []string{"echo"}, `foo\`, line.ErrUnterminatedEscape}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + words, remainder, err := splitCompWords(tc.input) + if err != tc.wantErr { + t.Fatalf("splitCompWords(%q) err = %v, want %v", tc.input, err, tc.wantErr) + } + if !reflect.DeepEqual(words, tc.wantWords) { + t.Fatalf("splitCompWords(%q) words = %q, want %q", tc.input, words, tc.wantWords) + } + if remainder != tc.wantRemainder { + t.Fatalf("splitCompWords(%q) remainder = %q, want %q", tc.input, remainder, tc.wantRemainder) + } + }) + } +} + +func TestAdjustQuotedPrefix(t *testing.T) { + tests := []struct { + name string + remain string + err error + wantArg string + wantComp string + wantInput string + }{ + {"no error", "foo", nil, "foo", "", ""}, + {"double quote", "foo", line.ErrUnterminatedDoubleQuote, "foo", `"`, `"foo`}, + {"single quote", "foo", line.ErrUnterminatedSingleQuote, "foo", "'", "'foo"}, + {"escape strips backslashes", `fo\o`, line.ErrUnterminatedEscape, "foo", "", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + arg, comp, input := adjustQuotedPrefix(tc.remain, tc.err) + if arg != tc.wantArg || comp != tc.wantComp || input != tc.wantInput { + t.Fatalf("adjustQuotedPrefix(%q) = (%q, %q, %q), want (%q, %q, %q)", + tc.remain, arg, comp, input, tc.wantArg, tc.wantComp, tc.wantInput) + } + }) + } +} + +func TestSanitizeArgs(t *testing.T) { + got := sanitizeArgs([]string{"a\nb", "c\td", `e\ f`}) + want := []string{"a b", "c d", "e f"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("sanitizeArgs = %q, want %q", got, want) + } +} + +func TestSplitArgs(t *testing.T) { + tests := []struct { + name string + input string + wantArgs []string + wantPrefixComp string + wantPrefixLine string + }{ + {"empty line completes root", "", []string{""}, "", ""}, + {"partial word", "cmd", []string{"cmd"}, "", ""}, + {"trailing space starts new word", "cmd ", []string{"cmd", ""}, "", ""}, + {"two words", "cmd arg", []string{"cmd", "arg"}, "", ""}, + {"unterminated double quote", `cmd "foo`, []string{"cmd", "foo"}, `"`, `"foo`}, + {"unterminated single quote", "cmd 'foo", []string{"cmd", "foo"}, "'", "'foo"}, + {"color codes stripped", "\x1b[32mcmd\x1b[0m", []string{"cmd"}, "", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + runes := []rune(tc.input) + args, prefixComp, prefixLine := SplitArgs(runes, len(runes)) + if !reflect.DeepEqual(args, tc.wantArgs) { + t.Fatalf("SplitArgs(%q) args = %q, want %q", tc.input, args, tc.wantArgs) + } + if prefixComp != tc.wantPrefixComp { + t.Fatalf("SplitArgs(%q) prefixComp = %q, want %q", tc.input, prefixComp, tc.wantPrefixComp) + } + if prefixLine != tc.wantPrefixLine { + t.Fatalf("SplitArgs(%q) prefixLine = %q, want %q", tc.input, prefixLine, tc.wantPrefixLine) + } + }) + } +} diff --git a/internal/line/highlight.go b/internal/line/highlight.go index b84ebd1..3a2a302 100644 --- a/internal/line/highlight.go +++ b/internal/line/highlight.go @@ -41,13 +41,10 @@ func HighlightCommand(done, args []string, root *cobra.Command, cmdColor string) // Highlight the root command when found, or any of its aliases. for _, cmd := range root.Commands() { - // Change 1: Highlight based on first arg in usage rather than the entire usage itself - cmdFound := strings.Split(cmd.Use, " ")[0] == strings.TrimSpace(args[0]) - - if slices.Contains(cmd.Aliases, strings.TrimSpace(args[0])) { - cmdFound = true - break - } + // Highlight based on first arg in usage rather than the entire usage itself, + // or on any of the command's aliases. + name := strings.TrimSpace(args[0]) + cmdFound := strings.Split(cmd.Use, " ")[0] == name || slices.Contains(cmd.Aliases, name) if cmdFound { highlighted = append(highlighted, Bold+cmdColor+args[0]+ResetFG+BoldReset) diff --git a/internal/line/highlight_test.go b/internal/line/highlight_test.go new file mode 100644 index 0000000..5aac0e4 --- /dev/null +++ b/internal/line/highlight_test.go @@ -0,0 +1,69 @@ +package line + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestHighlightCommandAlias is a regression test for a bug where a command +// invoked through one of its aliases was never highlighted: the alias branch +// used to `break` out of the loop before reaching the highlight block. +func TestHighlightCommandAlias(t *testing.T) { + root := &cobra.Command{Use: "app"} + root.AddCommand(&cobra.Command{Use: "deploy host", Aliases: []string{"d", "dep"}}) + + tests := []struct { + name string + arg string + want bool // whether arg should be highlighted as a command + }{ + {"canonical name", "deploy", true}, + {"first alias", "d", true}, + {"second alias", "dep", true}, + {"unknown word", "nope", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + done, _ := HighlightCommand(nil, []string{tc.arg}, root, GreenFG) + + highlighted := len(done) > 0 && strings.Contains(done[0], GreenFG) + if highlighted != tc.want { + t.Fatalf("arg %q: highlighted=%v, want %v (got %q)", tc.arg, highlighted, tc.want, done) + } + }) + } +} + +func TestHighlightCommandFlags(t *testing.T) { + args := []string{"--verbose", "target", "-x", "value"} + done, _ := HighlightCommandFlags(nil, args, BrightWhiteFG) + + if len(done) != len(args) { + t.Fatalf("HighlightCommandFlags returned %d words, want %d: %q", len(done), len(args), done) + } + + tests := []struct { + idx int + shouldHighlit bool + raw string + }{ + {0, true, "--verbose"}, + {1, false, "target"}, + {2, true, "-x"}, + {3, false, "value"}, + } + + for _, tc := range tests { + got := done[tc.idx] + colored := strings.Contains(got, BrightWhiteFG) + if colored != tc.shouldHighlit { + t.Errorf("word %q: highlighted=%v, want %v (got %q)", tc.raw, colored, tc.shouldHighlit, got) + } + if !strings.Contains(got, tc.raw) { + t.Errorf("word %d: %q does not contain original %q", tc.idx, got, tc.raw) + } + } +} diff --git a/internal/line/line_test.go b/internal/line/line_test.go new file mode 100644 index 0000000..192e589 --- /dev/null +++ b/internal/line/line_test.go @@ -0,0 +1,156 @@ +package line + +import ( + "errors" + "reflect" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + {"empty", "", nil, false}, + {"simple", "echo hello", []string{"echo", "hello"}, false}, + {"extra spaces collapse", "echo hello world", []string{"echo", "hello", "world"}, false}, + {"trailing comment", "echo hello # a comment", []string{"echo", "hello"}, false}, + {"comment only", "# just a comment", nil, false}, + {"single quotes", "echo 'hello world'", []string{"echo", "hello world"}, false}, + {"double quotes", `echo "hello world"`, []string{"echo", "hello world"}, false}, + {"quoted hash not a comment", "echo '# not a comment'", []string{"echo", "# not a comment"}, false}, + {"unterminated single quote", "echo 'oops", nil, true}, + {"unterminated double quote", `echo "oops`, nil, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Parse(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("Parse(%q): expected error, got nil (words=%q)", tc.input, got) + } + return + } + if err != nil { + t.Fatalf("Parse(%q): unexpected error: %v", tc.input, err) + } + if len(got) == 0 && len(tc.want) == 0 { + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Parse(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestSplit(t *testing.T) { + tests := []struct { + name string + input string + wantWords []string + wantErr error + }{ + {"empty", "", []string{}, nil}, + {"simple", "echo hello", []string{"echo", "hello"}, nil}, + {"single quotes", "echo 'hello world'", []string{"echo", "hello world"}, nil}, + {"double quotes", `echo "hello world"`, []string{"echo", "hello world"}, nil}, + {"escaped space", `echo foo\ bar`, []string{"echo", "foo bar"}, nil}, + {"unterminated single", "echo 'oops", []string{"echo"}, ErrUnterminatedSingleQuote}, + {"unterminated double", `echo "oops`, []string{"echo"}, ErrUnterminatedDoubleQuote}, + {"trailing backslash", `echo foo\`, []string{"echo"}, ErrUnterminatedEscape}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + words, _, err := Split(tc.input, false) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("Split(%q) err = %v, want %v", tc.input, err, tc.wantErr) + } + if !reflect.DeepEqual(words, tc.wantWords) { + t.Fatalf("Split(%q) words = %q, want %q", tc.input, words, tc.wantWords) + } + }) + } +} + +func TestAcceptMultiline(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"complete", "echo hello", true}, + {"complete quoted", `echo "hello world"`, true}, + {"unterminated single quote", "echo 'oops", false}, + {"unterminated double quote", `echo "oops`, false}, + {"trailing backslash", `echo foo\`, false}, + {"empty", "", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := AcceptMultiline([]rune(tc.input)); got != tc.want { + t.Fatalf("AcceptMultiline(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestIsEmpty(t *testing.T) { + empty := []rune{' ', '\t'} + + tests := []struct { + name string + input string + chars []rune + want bool + }{ + {"empty string", "", empty, true}, + {"only spaces", " ", empty, true}, + {"spaces and tabs", " \t \t ", empty, true}, + {"has content", " x ", empty, false}, + {"content no chars", "abc", nil, false}, + {"newline not in set", "\n", empty, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := IsEmpty(tc.input, tc.chars...); got != tc.want { + t.Fatalf("IsEmpty(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestTrimSpaces(t *testing.T) { + got := TrimSpaces([]string{" a ", "b\t", "\tc"}) + want := []string{"a", "b", "c"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("TrimSpaces = %q, want %q", got, want) + } +} + +func TestUnescapeValue(t *testing.T) { + tests := []struct { + name string + prefixLine string + val string + want string + }{ + {"double-quoted unescapes spaces", `"foo`, `bar\ baz`, "bar baz"}, + {"single-quoted unescapes spaces", `'foo`, `bar\ baz`, "bar baz"}, + {"unquoted left as-is", "foo", `bar\ baz`, `bar\ baz`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := UnescapeValue("", tc.prefixLine, tc.val); got != tc.want { + t.Fatalf("UnescapeValue(%q, %q) = %q, want %q", tc.prefixLine, tc.val, got, tc.want) + } + }) + } +} diff --git a/internal/strutil/template_test.go b/internal/strutil/template_test.go new file mode 100644 index 0000000..3bfd5d9 --- /dev/null +++ b/internal/strutil/template_test.go @@ -0,0 +1,52 @@ +package strutil + +import ( + "strings" + "testing" +) + +func TestTemplate(t *testing.T) { + tests := []struct { + name string + text string + data any + want string + }{ + { + name: "simple field", + text: "Hello {{.Name}}", + data: map[string]any{"Name": "world"}, + want: "Hello world", + }, + { + name: "trim func", + text: "[{{trim .S}}]", + data: map[string]any{"S": " padded "}, + want: "[padded]", + }, + { + name: "range over slice", + text: "{{range .Items}}{{.}},{{end}}", + data: map[string]any{"Items": []string{"a", "b", "c"}}, + want: "a,b,c,", + }, + { + name: "no substitution", + text: "static text", + data: nil, + want: "static text", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var b strings.Builder + if err := Template(&b, tc.text, tc.data); err != nil { + t.Fatalf("Template(%q): unexpected error: %v", tc.text, err) + } + if got := b.String(); got != tc.want { + t.Fatalf("Template(%q) = %q, want %q", tc.text, got, tc.want) + } + }) + } +} diff --git a/internal/ui/prompt.go b/internal/ui/prompt.go index 750d38d..a7b590c 100644 --- a/internal/ui/prompt.go +++ b/internal/ui/prompt.go @@ -27,8 +27,6 @@ func NewPrompt(appName, menuName string, stdout *bytes.Buffer) *Prompt { prompt.Primary = func() string { promptStr := appName - // menu := app.activeMenu() - if menuName == "" { return promptStr + " > " } @@ -51,17 +49,14 @@ func NewPrompt(appName, menuName string, stdout *bytes.Buffer) *Prompt { func BindPrompt(p *Prompt, shell *readline.Shell) { prompt := shell.Prompt - // If the user has bound its own primary prompt and the shell - // must leave a newline after command/log output, wrap its function - // to add a newline before the prompt. + // Guard against a nil primary prompt, since the shell calls this on + // every render. Newlines around the prompt are handled by readline. primary := func() string { if p.Primary == nil { return "" } - prompt := p.Primary() - - return prompt + return p.Primary() } prompt.Primary(primary) diff --git a/interrupt.go b/interrupt.go index a70ba33..c0f952c 100644 --- a/interrupt.go +++ b/interrupt.go @@ -1,23 +1,30 @@ package console +import "errors" + // AddInterrupt registers a handler to run when the console receives // a given interrupt error from the underlying readline shell. // // On most systems, the following errors will be returned with keypresses: // - Linux/MacOS/Windows : Ctrl-C will return os.Interrupt. // +// The incoming error is matched against the registered one with errors.Is +// first (so wrapped errors and sentinel values work as expected), falling +// back to comparing their messages for errors that are merely value-equal +// (e.g. two distinct errors.New with the same text). +// // Many will want to use this to switch menus. Note that these interrupt errors only // work when the console is NOT currently executing a command, only when reading input. func (m *Menu) AddInterrupt(err error, handler func(c *Console)) { - m.mutex.RLock() + m.mutex.Lock() m.interruptHandlers[err] = handler - m.mutex.RUnlock() + m.mutex.Unlock() } // DelInterrupt removes one or more interrupt handlers from the menu registered ones. // If no error is passed as argument, all handlers are removed. func (m *Menu) DelInterrupt(errs ...error) { - m.mutex.RLock() + m.mutex.Lock() if len(errs) == 0 { m.interruptHandlers = make(map[error]func(c *Console)) } else { @@ -25,30 +32,30 @@ func (m *Menu) DelInterrupt(errs ...error) { delete(m.interruptHandlers, err) } } - m.mutex.RUnlock() + m.mutex.Unlock() } func (m *Menu) handleInterrupt(err error) { - m.console.mutex.RLock() - m.console.isExecuting = true - m.console.mutex.RUnlock() + m.console.isExecuting.Store(true) + defer m.console.isExecuting.Store(false) - defer func() { - m.console.mutex.RLock() - m.console.isExecuting = false - m.console.mutex.RUnlock() - }() - - // TODO: this is not a very, very safe way of comparing - // errors. I'm not sure what to right now with this, but - // from my (unreliable) expectations and usage, I see and - // use things like errors.New(os.Interrupt.String()), so - // the string itself is likely to change in the future. + // Match with errors.Is first so sentinel and wrapped errors behave + // correctly, then fall back to comparing messages for errors that are + // only value-equal (the historically supported errors.New(...) pattern). // - // But if people use their own third-party errors... nothing is guaranteed. + // Snapshot the matching handlers under the lock, then run them once + // released: a handler is free to mutate the menu (e.g. SwitchMenu) + // without deadlocking, and the map can't be written mid-iteration. + m.mutex.RLock() + matched := make([]func(c *Console), 0, len(m.interruptHandlers)) for herr, handler := range m.interruptHandlers { - if err.Error() == herr.Error() { - handler(m.console) + if errors.Is(err, herr) || err.Error() == herr.Error() { + matched = append(matched, handler) } } + m.mutex.RUnlock() + + for _, handler := range matched { + handler(m.console) + } } diff --git a/menu.go b/menu.go index ada9bcd..5ee29f8 100644 --- a/menu.go +++ b/menu.go @@ -56,6 +56,13 @@ type Menu struct { historyNames []string histories map[string]readline.History + // Per-menu overrides of the console newline behavior. When a *bool is nil + // (or emptyChars is nil), the corresponding Console default is used. + nlBefore *bool + nlAfter *bool + nlWhenEmpty *bool + emptyChars []rune + // Concurrency management mutex *sync.RWMutex } @@ -98,11 +105,84 @@ func (m *Menu) Prompt() *Prompt { return m.prompt } +// SetNewlineBefore overrides Console.NewlineBefore for this menu only. +func (m *Menu) SetNewlineBefore(v bool) { + m.mutex.Lock() + m.nlBefore = &v + m.mutex.Unlock() +} + +// SetNewlineAfter overrides Console.NewlineAfter for this menu only. +func (m *Menu) SetNewlineAfter(v bool) { + m.mutex.Lock() + m.nlAfter = &v + m.mutex.Unlock() +} + +// SetNewlineWhenEmpty overrides Console.NewlineWhenEmpty for this menu only. +func (m *Menu) SetNewlineWhenEmpty(v bool) { + m.mutex.Lock() + m.nlWhenEmpty = &v + m.mutex.Unlock() +} + +// SetEmptyChars overrides Console.EmptyChars for this menu only. Passing no +// arguments clears the override, restoring the console default. +func (m *Menu) SetEmptyChars(chars ...rune) { + m.mutex.Lock() + m.emptyChars = chars + m.mutex.Unlock() +} + +func (m *Menu) newlineBefore() bool { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if m.nlBefore != nil { + return *m.nlBefore + } + + return m.console.NewlineBefore +} + +func (m *Menu) newlineAfter() bool { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if m.nlAfter != nil { + return *m.nlAfter + } + + return m.console.NewlineAfter +} + +func (m *Menu) newlineWhenEmpty() bool { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if m.nlWhenEmpty != nil { + return *m.nlWhenEmpty + } + + return m.console.NewlineWhenEmpty +} + +func (m *Menu) emptyCharSet() []rune { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if m.emptyChars != nil { + return m.emptyChars + } + + return m.console.EmptyChars +} + // AddHistorySource adds a source of history commands that will // be accessible to the shell when the menu is active. func (m *Menu) AddHistorySource(name string, source readline.History) { - m.mutex.RLock() - defer m.mutex.RUnlock() + m.mutex.Lock() + defer m.mutex.Unlock() if len(m.histories) == 1 && m.historyNames[0] == m.defaultHistoryName() { delete(m.histories, m.defaultHistoryName()) @@ -117,8 +197,8 @@ func (m *Menu) AddHistorySource(name string, source readline.History) { // to the specified "filepath" parameter. On the first call to this function, // the default in-memory history source is removed. func (m *Menu) AddHistorySourceFile(name string, filepath string) { - m.mutex.RLock() - defer m.mutex.RUnlock() + m.mutex.Lock() + defer m.mutex.Unlock() if len(m.histories) == 1 && m.historyNames[0] == m.defaultHistoryName() { delete(m.histories, m.defaultHistoryName()) @@ -242,23 +322,33 @@ func (m *Menu) CheckIsAvailable(cmd *cobra.Command) error { // ActiveFiltersFor returns all the active menu filters that a given command // does not declare as compliant with (added with console.Hide/ShowCommand()). func (m *Menu) ActiveFiltersFor(cmd *cobra.Command) []string { + // Snapshot the console filters once under a read lock, then walk the + // command tree lock-free. The previous version held a write lock and + // recursed into itself while holding it, which both serialized every + // completion/highlight render and risked a self-deadlock on the + // (non-reentrant) mutex whenever the parent-subtree branch was taken. + m.console.mutex.RLock() + consoleFilters := append([]string(nil), m.console.filters...) + m.console.mutex.RUnlock() + + return activeFiltersFor(cmd, consoleFilters) +} + +func activeFiltersFor(cmd *cobra.Command, consoleFilters []string) []string { if cmd.Annotations == nil { if cmd.HasParent() { - return m.ActiveFiltersFor(cmd.Parent()) + return activeFiltersFor(cmd.Parent(), consoleFilters) } return nil } - m.console.mutex.Lock() - defer m.console.mutex.Unlock() - // Get the filters on the command filterStr := cmd.Annotations[CommandFilterKey] var filters []string for _, cmdFilter := range strings.Split(filterStr, ",") { - for _, filter := range m.console.filters { + for _, filter := range consoleFilters { if cmdFilter != "" && cmdFilter == filter { filters = append(filters, cmdFilter) } @@ -270,7 +360,7 @@ func (m *Menu) ActiveFiltersFor(cmd *cobra.Command) []string { } // Any parent that is hidden make its whole subtree hidden also. - return m.ActiveFiltersFor(cmd.Parent()) + return activeFiltersFor(cmd.Parent(), consoleFilters) } // SetErrFilteredCommandTemplate sets the error template to be used @@ -287,6 +377,30 @@ func (m *Menu) resetPreRun() { defer m.mutex.Unlock() // Commands + m.regenerate() + + // Reset or adjust any buffered command output. + m.resetCmdOutput() + + // Prompt binding + prompt := (*ui.Prompt)(m.Prompt()) + ui.BindPrompt(prompt, m.console.shell) +} + +// resetCommands regenerates the menu command tree and re-applies filtering, +// without rebinding the prompt or touching the command-output buffer. It is the +// lighter reset used on the completion hot path, where the prompt is already +// bound and no command output was produced. +func (m *Menu) resetCommands() { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.regenerate() +} + +// regenerate rebuilds the command tree and hides filtered commands. +// It assumes m.mutex is already held. +func (m *Menu) regenerate() { if m.cmds != nil { m.Command = m.cmds() } @@ -297,15 +411,11 @@ func (m *Menu) resetPreRun() { } } - // Hide commands that are not available + // Hide commands that are not available. m.hideFilteredCommands(m.Command) - // Reset or adjust any buffered command output. - m.resetCmdOutput() - - // Prompt binding - prompt := (*ui.Prompt)(m.Prompt()) - ui.BindPrompt(prompt, m.console.shell) + // The command tree just changed, so any memoized highlight is now stale. + m.console.hlCache.Store(nil) } // hide commands that are filtered so that they are not @@ -327,7 +437,7 @@ func (m *Menu) resetCmdOutput() { buf := strings.TrimSpace(m.out.String()) // If our command has printed everything to stdout, nothing to do. - if len(buf) == 0 || buf == "" { + if len(buf) == 0 { m.out.Reset() return } diff --git a/newline_display_test.go b/newline_display_test.go new file mode 100644 index 0000000..10c9070 --- /dev/null +++ b/newline_display_test.go @@ -0,0 +1,108 @@ +package console + +import ( + "io" + "os" + "testing" +) + +// captureStdout redirects os.Stdout for the duration of fn and returns what was +// written. The display functions print via fmt.Println, which targets +// os.Stdout directly. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + + os.Stdout = w + fn() + os.Stdout = orig + + if err := w.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read: %v", err) + } + _ = r.Close() + + return string(out) +} + +func TestDisplayNewlineMatrix(t *testing.T) { + // A newline is printed iff: enabled && (whenEmpty || input is non-empty). + cases := []struct { + name string + enabled bool + whenEmpty bool + input string + wantNewline bool + }{ + {"disabled/empty", false, false, "", false}, + {"disabled/nonempty", false, false, "cmd", false}, + {"disabled/whenEmpty/nonempty", false, true, "cmd", false}, + + {"enabled/nonempty", true, false, "cmd", true}, + {"enabled/empty", true, false, "", false}, + {"enabled/spaces-are-empty", true, false, " \t ", false}, + + {"enabled/whenEmpty/empty", true, true, "", true}, + {"enabled/whenEmpty/nonempty", true, true, "cmd", true}, + {"enabled/whenEmpty/spaces", true, true, " \t ", true}, + } + + for _, tc := range cases { + want := "" + if tc.wantNewline { + want = "\n" + } + + t.Run("pre/"+tc.name, func(t *testing.T) { + c := New("test") + c.NewlineBefore = tc.enabled + c.NewlineWhenEmpty = tc.whenEmpty + + got := captureStdout(t, func() { c.displayPreRun(tc.input) }) + if got != want { + t.Fatalf("displayPreRun(%q) printed %q, want %q", tc.input, got, want) + } + }) + + t.Run("post/"+tc.name, func(t *testing.T) { + c := New("test") + c.NewlineAfter = tc.enabled + c.NewlineWhenEmpty = tc.whenEmpty + + got := captureStdout(t, func() { c.displayPostRun(tc.input) }) + if got != want { + t.Fatalf("displayPostRun(%q) printed %q, want %q", tc.input, got, want) + } + }) + } +} + +// TestDisplayNewlineMenuOverride checks that a per-menu newline override is +// honored by the display path even when the console default differs. +func TestDisplayNewlineMenuOverride(t *testing.T) { + c := New("test") + c.NewlineAfter = false // console default: off + c.ActiveMenu().SetNewlineAfter(true) + + if got := captureStdout(t, func() { c.displayPostRun("cmd") }); got != "\n" { + t.Fatalf("menu override on: displayPostRun printed %q, want %q", got, "\n") + } + + // And the inverse: console on, menu override off. + c.NewlineAfter = true + c.ActiveMenu().SetNewlineAfter(false) + + if got := captureStdout(t, func() { c.displayPostRun("cmd") }); got != "" { + t.Fatalf("menu override off: displayPostRun printed %q, want %q", got, "") + } +} diff --git a/run.go b/run.go index ab9340b..8cd2653 100644 --- a/run.go +++ b/run.go @@ -21,7 +21,20 @@ func (c *Console) Start() error { return c.StartContext(context.Background()) } -// StartContext is like console.Start(). with a user-provided context. +// StartContext is like console.Start(), with a user-provided context. +// +// Cancellation model: each command runs with a context derived from ctx, +// accessible from within the command via cmd.Context(). When the console +// traps one of its Signals (SIGINT/SIGTERM/SIGQUIT by default) while a command +// is running, that command's context is cancelled and any registered interrupt +// handler for the menu is invoked. Cancelling ctx itself does the same on the +// next command boundary. +// +// Because cobra cannot preempt a running command, a long-running command is +// only actually interrupted if it observes cancellation itself: select on +// cmd.Context().Done() (or pass cmd.Context() to context-aware callees) and +// return promptly. A command that ignores its context keeps running in its +// goroutine until it finishes, even though the prompt has already been freed. func (c *Console) StartContext(ctx context.Context) error { c.loadActiveHistories() @@ -110,7 +123,7 @@ func (m *Menu) RunCommandArgs(ctx context.Context, args []string) (err error) { m.resetPreRun() // Run the command and associated helpers. - return m.console.execute(ctx, m, args, !m.console.isExecuting) + return m.console.execute(ctx, m, args, !m.console.isExecuting.Load()) } // RunCommandLine is the equivalent of menu.RunCommandArgs(), but accepts @@ -138,16 +151,10 @@ func (m *Menu) RunCommandLine(ctx context.Context, line string) (err error) { // command is running, the menu's root command will be overwritten. func (c *Console) execute(ctx context.Context, menu *Menu, args []string, async bool) error { if !async { - c.mutex.RLock() - c.isExecuting = true - c.mutex.RUnlock() + c.isExecuting.Store(true) } - defer func() { - c.mutex.RLock() - c.isExecuting = false - c.mutex.RUnlock() - }() + defer c.isExecuting.Store(false) // Our root command of interest, used throughout this function. cmd := menu.Command @@ -174,7 +181,11 @@ func (c *Console) execute(ctx context.Context, menu *Menu, args []string, async cmd.SetContext(ctx) // Start monitoring keyboard and OS signals. + // signal.Stop releases the channel registration once the command + // returns: without it, every command execution would leak a channel + // in the os/signal package for the lifetime of the process. sigchan := c.monitorSignals() + defer signal.Stop(sigchan) // And start the command execution. go c.executeCommand(cmd, cancel) @@ -251,43 +262,42 @@ func (c *Console) runLineHooks(args []string) ([]string, error) { } func (c *Console) displayPreRun(input string) { - if c.NewlineBefore { - if !c.NewlineWhenEmpty { - if !line.IsEmpty(input, c.EmptyChars...) { - fmt.Println() - } - } else { - fmt.Println() - } + menu := c.activeMenu() + + if menu.newlineBefore() && (menu.newlineWhenEmpty() || !line.IsEmpty(input, menu.emptyCharSet()...)) { + fmt.Println() } } func (c *Console) displayPostRun(lastLine string) { - if c.NewlineAfter { - if !c.NewlineWhenEmpty { - if !line.IsEmpty(lastLine, c.EmptyChars...) { - fmt.Println() - } - } else { - fmt.Println() - } + menu := c.activeMenu() + + if menu.newlineAfter() && (menu.newlineWhenEmpty() || !line.IsEmpty(lastLine, menu.emptyCharSet()...)) { + fmt.Println() } c.printed = false } +// defaultTrapSignals are the OS signals the console traps while a command is +// running when Console.Signals has not been customized. +var defaultTrapSignals = []os.Signal{ + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGQUIT, +} + // monitorSignals - Monitor the signals that can be sent to the process // while a command is running. We want to be able to cancel the command. -func (c *Console) monitorSignals() <-chan os.Signal { +func (c *Console) monitorSignals() chan os.Signal { sigchan := make(chan os.Signal, 1) - signal.Notify( - sigchan, - syscall.SIGINT, - syscall.SIGTERM, - syscall.SIGQUIT, - // syscall.SIGKILL, - ) + signals := c.Signals + if len(signals) == 0 { + signals = defaultTrapSignals + } + + signal.Notify(sigchan, signals...) return sigchan } diff --git a/signals_unix_test.go b/signals_unix_test.go new file mode 100644 index 0000000..5796c7b --- /dev/null +++ b/signals_unix_test.go @@ -0,0 +1,35 @@ +//go:build unix + +package console + +import ( + "os" + "os/signal" + "syscall" + "testing" + "time" +) + +// TestMonitorSignalsCustom verifies that monitorSignals honors a customized +// Console.Signals set. SIGUSR1 is used because it is not part of the default +// trapped set and is not sent by the test harness. +func TestMonitorSignalsCustom(t *testing.T) { + c := New("test") + c.Signals = []os.Signal{syscall.SIGUSR1} + + ch := c.monitorSignals() + defer signal.Stop(ch) + + if err := syscall.Kill(os.Getpid(), syscall.SIGUSR1); err != nil { + t.Fatalf("failed to raise SIGUSR1: %v", err) + } + + select { + case got := <-ch: + if got != syscall.SIGUSR1 { + t.Fatalf("received %v, want SIGUSR1", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for the custom signal") + } +}