From 9b54bde17133117669b748310cbbaf036c897904 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 25 May 2026 09:20:56 +0300 Subject: fix: return readline suffixes instead of full matches Per the readline AutoCompleter docs, Do() should return suffixes (the characters AFTER the common prefix), not full matches. The length parameter indicates how many characters are shared. This fixes the bug where readline appended the full match to the typed input (e.g. 'he' + 'help' = 'hehelp') because commonLen was 0 and readline had no idea what to replace. Now 'he' returns suffix 'lp' with commonLen=2, so readline correctly replaces 'he' with 'help'. --- internal/repl/completer.go | 55 +++++++++++++++++++++++++------- internal/repl/completer_adapter_test.go | 56 ++++++++++++++++++++------------- 2 files changed, 78 insertions(+), 33 deletions(-) (limited to 'internal') diff --git a/internal/repl/completer.go b/internal/repl/completer.go index e19a521..c664ca8 100644 --- a/internal/repl/completer.go +++ b/internal/repl/completer.go @@ -104,24 +104,57 @@ func (a *AutoCompleteAdapter) completeHelpTopics(lastWord string) ([][]rune, int return a.withCommonPrefix(matches, lastWord) } -// withCommonPrefix calculates the common prefix adjustment for readline. +// withCommonPrefix calculates the common prefix and returns suffixes +// as required by the readline AutoCompleter interface. +// Per the readline docs, candidates should be the characters AFTER the +// common prefix, and length is the common prefix length. +// Example: input "he" matches "help" => return [["lp"]], 2 func (a *AutoCompleteAdapter) withCommonPrefix(matches [][]rune, lastWord string) ([][]rune, int) { if len(matches) == 0 { return matches, 0 } - // Find common prefix length - minLen := len(lastWord) - for _, m := range matches { - compare := string(m) - i := 0 - for i < len(lastWord) && i < len(compare) && lastWord[i] == compare[i] { - i++ + // Find the common prefix length across all matches. + minLen := len(matches[0]) + for _, m := range matches[1:] { + if len(m) < minLen { + minLen = len(m) } - if i < minLen { - minLen = i + } + for i := 0; i < minLen; i++ { + ch := matches[0][i] + for _, m := range matches[1:] { + if m[i] != ch { + minLen = i + break + } + } + if minLen == i { + break + } + } + + // Also cap by the number of characters that match the typed input. + // Case-insensitive matches (e.g. "HELP" -> "help") have 0 shared chars. + inputWord := []rune(lastWord) + sharedWithInput := 0 + for i := 0; i < minLen && i < len(inputWord); i++ { + // Check against first match (all matches share this prefix) + if i < len(matches[0]) && matches[0][i] == inputWord[i] { + sharedWithInput++ + } else { + break } } + if sharedWithInput < minLen { + minLen = sharedWithInput + } + + // Return only the suffixes (characters after the common prefix). + suffixes := make([][]rune, len(matches)) + for i, m := range matches { + suffixes[i] = m[minLen:] + } - return matches, minLen - len(lastWord) + return suffixes, minLen } diff --git a/internal/repl/completer_adapter_test.go b/internal/repl/completer_adapter_test.go index 5f94a3b..72d3c9d 100644 --- a/internal/repl/completer_adapter_test.go +++ b/internal/repl/completer_adapter_test.go @@ -4,6 +4,7 @@ package repl import ( + "strings" "testing" ) @@ -63,34 +64,34 @@ func TestAutoCompleteAdapterDo(t *testing.T) { wantLen: 0, wantMinLen: 0, }, - // Partial matches (single match, commonLen always 0 since minLen capped at len(lastWord)) + // Partial matches (single match, commonLen = shared prefix length) { name: "partial match he", line: []rune("he"), pos: 2, wantLen: 1, - wantMinLen: 0, + wantMinLen: 2, // "he" shared, suffix "lp" }, { name: "partial match cl", line: []rune("cl"), pos: 2, wantLen: 1, - wantMinLen: 0, + wantMinLen: 2, // "cl" shared, suffix "ear" }, { name: "partial match q", line: []rune("q"), pos: 1, wantLen: 1, - wantMinLen: 0, + wantMinLen: 1, // "q" shared, suffix "uit" }, { name: "partial match rp", line: []rune("rp"), pos: 2, wantLen: 1, - wantMinLen: 0, + wantMinLen: 2, // "rp" shared, suffix "n" }, // Multiple matches { @@ -98,7 +99,7 @@ func TestAutoCompleteAdapterDo(t *testing.T) { line: []rune("c"), pos: 1, wantLen: 2, - wantMinLen: 0, + wantMinLen: 1, // "c" shared, suffixes "alc" and "lear" }, // No matches { @@ -129,41 +130,41 @@ func TestAutoCompleteAdapterDo(t *testing.T) { wantLen: 0, wantMinLen: 0, }, - // Case insensitive + // Case insensitive — case mismatch means no shared prefix { name: "uppercase HELP", line: []rune("HELP"), pos: 4, wantLen: 1, - wantMinLen: -4, // HELP vs help: no case match in prefix calc + wantMinLen: 0, // H vs h: no shared prefix }, { name: "uppercase CLEAR", line: []rune("CLEAR"), pos: 5, wantLen: 1, - wantMinLen: -5, + wantMinLen: 0, // C vs c: no shared prefix }, { name: "mixed case HeLp", line: []rune("HeLp"), pos: 4, wantLen: 1, - wantMinLen: -4, + wantMinLen: 0, // H vs h: no shared prefix }, { name: "uppercase Q matches quit", line: []rune("Q"), pos: 1, wantLen: 1, - wantMinLen: -1, + wantMinLen: 0, // Q vs q: no shared prefix }, { name: "uppercase C matches calc and clear", line: []rune("C"), pos: 1, wantLen: 2, - wantMinLen: -1, + wantMinLen: 0, // C vs c: no shared prefix }, // Multi-word input completes last word (exact match returns nothing) { @@ -179,29 +180,28 @@ func TestAutoCompleteAdapterDo(t *testing.T) { line: []rune("help"), pos: 2, wantLen: 1, - wantMinLen: 0, + wantMinLen: 2, // "he" shared, suffix "lp" }, - // Single character prefixes { name: "single char h", line: []rune("h"), pos: 1, wantLen: 1, - wantMinLen: 0, + wantMinLen: 1, // "h" shared, suffix "elp" }, { name: "single char e matches exit", line: []rune("e"), pos: 1, wantLen: 1, - wantMinLen: 0, + wantMinLen: 1, // "e" shared, suffix "xit" }, { name: "single char r matches rpn and rat", line: []rune("r"), pos: 1, wantLen: 2, - wantMinLen: 0, + wantMinLen: 1, // "r" shared, suffixes "pn" and "at" }, } @@ -218,12 +218,23 @@ func TestAutoCompleteAdapterDo(t *testing.T) { t.Errorf("common prefix len = %d, want %d", commonLen, tt.wantMinLen) } - // Verify all matches are actual commands + // Verify all matches are actual commands (prefix + suffix) + prefixWords := strings.Fields(string(tt.line[:tt.pos])) + var prefix string + if len(prefixWords) > 0 { + prefix = prefixWords[len(prefixWords)-1] + } for _, m := range matches { - matchStr := string(m) + matchStr := prefix + string(m) + // For case-insensitive matches, also check case-folded found := false for _, cmd := range commands { - if cmd == matchStr { + if cmd == matchStr || strings.EqualFold(cmd, matchStr) { + found = true + break + } + // Case-insensitive: the suffix itself (lowercased) should be a valid command + if commonLen == 0 && strings.EqualFold(string(m), cmd) { found = true break } @@ -255,8 +266,9 @@ func TestAutoCompleteAdapterDoPreserveCommandOrder(t *testing.T) { } } - match0 := string(matches[0]) - match1 := string(matches[1]) + // Suffixes: prefix is "c", so matches are "alc" and "lear" + match0 := "c" + string(matches[0]) + match1 := "c" + string(matches[1]) if calcIdx < clearIdx { if match0 != "calc" || match1 != "clear" { -- cgit v1.2.3