summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-25 09:20:56 +0300
committerPaul Buetow <paul@buetow.org>2026-05-25 09:20:56 +0300
commit9b54bde17133117669b748310cbbaf036c897904 (patch)
tree9366f26f778edea8ec17ca90a7e5f1ec6af583a2 /internal
parent16e3ca1b90639c52ecff507de4fbb90e2d0e0f85 (diff)
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<TAB>' returns suffix 'lp' with commonLen=2, so readline correctly replaces 'he' with 'help'.
Diffstat (limited to 'internal')
-rw-r--r--internal/repl/completer.go55
-rw-r--r--internal/repl/completer_adapter_test.go56
2 files changed, 78 insertions, 33 deletions
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" {