diff --git a/completion.go b/completion.go index 23a2aaf..f18b157 100644 --- a/completion.go +++ b/completion.go @@ -227,11 +227,24 @@ func (rl *Shell) startMenuComplete(completer completion.Completer) { } // commandCompletion generates the completions for commands/args/flags. -func (rl *Shell) commandCompletion() completion.Values { +// +// The application-provided completer is user code that readline calls on nearly +// every keystroke (menu completion and as-you-type autocomplete both funnel +// through here). A panic in it — a bad completer or transient command-tree +// state — is recovered and surfaced as a completion message, so a single faulty +// completion degrades into a visible error instead of crashing the shell. +func (rl *Shell) commandCompletion() (values completion.Values) { if rl.Completer == nil { return completion.Values{} } + defer func() { + if r := recover(); r != nil { + msg := CompleteMessage("completion error: %v", r) + values = msg.convert() + } + }() + line, cursor := rl.completer.Line() comps := rl.Completer(*line, cursor.Pos()) diff --git a/completion_test.go b/completion_test.go new file mode 100644 index 0000000..7b48aa0 --- /dev/null +++ b/completion_test.go @@ -0,0 +1,54 @@ +package readline + +import ( + "strings" + "testing" +) + +// TestCommandCompletionRecoversFromPanic ensures a panic in the +// application-provided completer is recovered and surfaced as a completion +// message instead of propagating and crashing the shell. readline calls the +// completer on nearly every keystroke, so a single bad completer or transient +// state must not take down the process. +func TestCommandCompletionRecoversFromPanic(t *testing.T) { + rl := NewShell() + rl.Completer = func([]rune, int) Completions { + panic("boom") + } + + defer func() { + if r := recover(); r != nil { + t.Fatalf("commandCompletion did not recover, panicked with: %v", r) + } + }() + + values := rl.commandCompletion() + + if values.Messages.IsEmpty() { + t.Fatal("recovered completion produced no message, want a completion error message") + } + + var found bool + for _, msg := range values.Messages.Get() { + if strings.Contains(msg, "completion error") && strings.Contains(msg, "boom") { + found = true + } + } + + if !found { + t.Fatalf("recovered messages = %v, want one containing the panic value", values.Messages.Get()) + } +} + +// TestCommandCompletionNilCompleter verifies the no-completer case stays a clean +// no-op (empty values, no message). +func TestCommandCompletionNilCompleter(t *testing.T) { + rl := NewShell() + rl.Completer = nil + + values := rl.commandCompletion() + + if !values.Messages.IsEmpty() { + t.Fatalf("nil completer produced messages %v, want none", values.Messages.Get()) + } +}