Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ func (rl *Shell) completeWord() {
rl.startMenuComplete(rl.commandCompletion)

if rl.Config.GetBool("menu-complete-display-prefix") {
if rl.completer.RequiresConfirmation() {
// Keep an explicit-confirmation candidate virtual so Escape can
// restore the original input and Enter can commit without submit.
rl.completer.Select(1, 0)
return
}
// Insert the prefix shared by all candidates, then display the
// menu without selecting one (GNU menu-complete-display-prefix).
rl.completer.InsertCommonPrefix()
Expand Down
246 changes: 246 additions & 0 deletions completion_accept_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package readline

import (
"testing"
"time"
)

func TestResetNotifiesSelectedPublicCompletion(t *testing.T) {
shell := NewShell()
accepted := ""
shell.Completer = func(_ []rune, _ int) Completions {
result := CompleteRaw([]Completion{
{Value: "sha256", Display: "sha256", Description: "hmac", Tag: "function", OnAccept: func(_ []rune, _ int) ([]rune, int) {
accepted = "hmac"
return []rune("import hmac"), len([]rune("import hmac"))
}},
{Value: "sha256", Display: "sha256", Description: "hash", Tag: "function", OnAccept: func(_ []rune, _ int) ([]rune, int) {
accepted = "hash"
return []rune("import hash"), len([]rune("import hash"))
}},
}).JustifyDescriptions()
result.PREFIX = "sha"
return result
}
if err := shell.Config.Set("menu-complete-display-prefix", true); err != nil {
t.Fatal(err)
}
shell.Line().Set([]rune("sha")...)
shell.Cursor().Set(3)

shell.completeWord()
shell.completeWord()
shell.completer.Reset()

if accepted == "" {
t.Fatal("selected completion did not call OnAccept")
}
if got := string(*shell.Line()); got != "import "+accepted {
t.Fatalf("line = %q, want transformed %s completion", got, accepted)
}
}

func TestConfirmationRequiredCompletionCanBeCancelledOrCommittedWithoutSubmit(t *testing.T) {
shell := NewShell()
accepted := false
shell.Completer = func(_ []rune, _ int) Completions {
result := CompleteRaw([]Completion{{
Value: "math", Display: "math", Description: "trb/std/math", Tag: "module",
RequireConfirmation: true,
OnAccept: func(_ []rune, _ int) ([]rune, int) {
accepted = true
line := []rune("import trb/std/math")
return line, len(line)
},
}})
result.PREFIX = "mat"
return result
}
if err := shell.Config.Set("menu-complete-display-prefix", true); err != nil {
t.Fatal(err)
}
shell.Line().Set([]rune("mat")...)
shell.Cursor().Set(3)

shell.completeWord()
if got := string(*shell.Line()); got != "mat" {
t.Fatalf("real line after completion = %q, want original input", got)
}
if !shell.completer.IsInserting() {
t.Fatal("confirmation-required completion was not kept virtual")
}
shell.abort()
if got := string(*shell.Line()); got != "mat" {
t.Fatalf("line after cancellation = %q, want original input", got)
}
if accepted {
t.Fatal("cancelled completion called OnAccept")
}

shell.completeWord()
shell.acceptLine()
if !accepted {
t.Fatal("confirmed completion did not call OnAccept")
}
if got := string(*shell.Line()); got != "import trb/std/math" {
t.Fatalf("line after confirmation = %q, want import", got)
}
if lineAccepted, _, _ := shell.History.LineAccepted(); lineAccepted {
t.Fatal("confirmation submitted the line")
}

shell.acceptLine()
lineAccepted, line, err := shell.History.LineAccepted()
if err != nil {
t.Fatal(err)
}
if !lineAccepted || line != "import trb/std/math" {
t.Fatalf("second accept = (%v, %q), want submitted import", lineAccepted, line)
}
}

func TestConfirmationRequiredCompletionConsumesFirstEnter(t *testing.T) {
shell := confirmationTestShell(t)
accepted := make(chan struct{}, 1)
shell.Completer = func(_ []rune, _ int) Completions {
result := CompleteRaw([]Completion{{
Value: "math", Display: "math", Description: "trb/std/math", Tag: "module",
RequireConfirmation: true,
OnAccept: func(_ []rune, _ int) ([]rune, int) {
accepted <- struct{}{}
line := []rune("import trb/std/math")
return line, len(line)
},
}})
result.PREFIX = "mat"
return result
}

shell.Keys.Feed(false, []rune("mat\t\r")...)
result := make(chan string, 1)
go func() {
line, _ := shell.Readline()
result <- line
}()

select {
case <-accepted:
case <-time.After(time.Second):
t.Fatal("first Enter did not confirm the completion")
}
select {
case line := <-result:
t.Fatalf("first Enter submitted %q instead of keeping it editable", line)
case <-time.After(50 * time.Millisecond):
}

shell.Keys.Feed(false, '\r')
shell.Keys.RequestRefresh()
select {
case line := <-result:
if line != "import trb/std/math" {
t.Fatalf("second Enter submitted %q, want import", line)
}
case <-time.After(time.Second):
t.Fatal("second Enter did not submit the confirmed import")
}
}

func TestConfirmationRequiredCompletionBackspaceCancelsSelection(t *testing.T) {
shell := confirmationTestShell(t)
shell.Completer = func(_ []rune, _ int) Completions {
result := CompleteRaw([]Completion{{
Value: "math", Display: "math", Description: "trb/std/math", Tag: "module",
RequireConfirmation: true,
OnAccept: func(_ []rune, _ int) ([]rune, int) {
t.Fatal("cancelled completion called OnAccept")
return nil, 0
},
}})
result.PREFIX = "mat"
return result
}

shell.Keys.Feed(false, []rune("mat\t\x7f\r")...)
line, err := shell.Readline()
if err != nil {
t.Fatal(err)
}
if line != "mat" {
t.Fatalf("line after Backspace cancellation = %q, want original input", line)
}
}

func TestConfirmationRequiredCompletionCommitCharacterKeepsExpression(t *testing.T) {
shell := confirmationTestShell(t)
shell.Completer = func(_ []rune, _ int) Completions {
result := CompleteRaw([]Completion{{
Value: "math", Display: "math", Description: "trb/std/math", Tag: "module",
RequireConfirmation: true,
CommitCharacters: ".",
OnAccept: func(_ []rune, _ int) ([]rune, int) {
t.Fatal("commit character used the Enter acceptance path")
return nil, 0
},
OnCommit: func(_ []rune, _ int, character rune) ([]rune, int) {
if character != '.' {
t.Fatalf("commit character = %q, want '.'", character)
}
line := []rune("import trb/std/math\nmath")
return line, len(line)
},
}})
result.PREFIX = "mat"
return result
}

shell.Keys.Feed(false, []rune("mat\t.sqrt(9)\r")...)
line, err := shell.Readline()
if err != nil {
t.Fatal(err)
}
if line != "import trb/std/math\nmath.sqrt(9)" {
t.Fatalf("committed line = %q, want imported expression", line)
}
}

func TestConfirmationRequiredCompletionOrdinaryInputCancelsSelection(t *testing.T) {
shell := confirmationTestShell(t)
shell.Completer = func(_ []rune, _ int) Completions {
result := CompleteRaw([]Completion{{
Value: "math", Display: "math", Description: "trb/std/math", Tag: "module",
RequireConfirmation: true,
CommitCharacters: ".",
OnAccept: func(_ []rune, _ int) ([]rune, int) {
t.Fatal("ordinary input accepted the candidate")
return nil, 0
},
}})
result.PREFIX = "mat"
return result
}

shell.Keys.Feed(false, []rune("mat\tx\r")...)
line, err := shell.Readline()
if err != nil {
t.Fatal(err)
}
if line != "matx" {
t.Fatalf("line after ordinary input = %q, want original input plus character", line)
}
}

func confirmationTestShell(t *testing.T) *Shell {
t.Helper()
shell := NewShell()
for name, value := range map[string]bool{
"cursor-position-probe": false,
"enable-bracketed-paste": false,
"menu-complete-display-prefix": true,
} {
if err := shell.Config.Set(name, value); err != nil {
t.Fatal(err)
}
}
return shell
}
6 changes: 6 additions & 0 deletions history.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,12 @@ func (rl *Shell) autosuggestDisable() {
//

func (rl *Shell) acceptLineWith(infer, hold bool) {
// Confirmation-required completions use the first Enter to commit the
// selected candidate to the editable input. A later Enter submits it.
if rl.completer.ConfirmSelection() {
return
}

// If we are currently using the incremental-search buffer,
// we should cancel this mode so as to run the rest of this
// function on (with) the input line itself, not the minibuffer.
Expand Down
15 changes: 15 additions & 0 deletions internal/completion/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ type Candidate struct {
Description string // A description to display next to the completion candidate.
Style string // An arbitrary string of color/text effects to use when displaying the completion.
Tag string // All completions with the same tag are grouped together and displayed under the tag heading.
// RequireConfirmation keeps this candidate virtual even when it is the only
// match. The first accept-line commits it to the editable input without
// submitting the line; a later accept-line submits the committed input.
RequireConfirmation bool
// CommitCharacters accepts this candidate before the matching character is
// inserted by the editor. This is useful for continuations such as '.' or
// '(' that should keep the line open.
CommitCharacters string
// OnAccept may transform the real input line after this candidate is
// accepted. The returned cursor is a rune offset into the returned line.
// Returning an invalid cursor leaves the accepted candidate unchanged.
OnAccept func(line []rune, cursor int) (accepted []rune, acceptedCursor int)
// OnCommit may apply a different transformation when a CommitCharacter
// accepts the candidate. When nil, OnAccept is used instead.
OnCommit func(line []rune, cursor int, character rune) (accepted []rune, acceptedCursor int)

displayLen int // Real length of the displayed candidate, that is not counting escaped sequences.
descLen int
Expand Down
30 changes: 28 additions & 2 deletions internal/completion/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func (e *Engine) Generate(completions Values) {
// Incremental search is a special case, because the user may
// want to keep searching for another match, so we don't drop
// the completion list and exit the incremental search mode.
if e.hasUniqueCandidate() && e.keymap.Local() != keymap.Isearch {
if e.hasUniqueCandidate() && e.keymap.Local() != keymap.Isearch && !e.requiresConfirmation() {
e.acceptCandidate()
e.ClearMenu(true)
}
Expand Down Expand Up @@ -336,6 +336,7 @@ func (e *Engine) Cancel(inserted, cached bool) {
} else {
e.line.Set(*e.compLine...)
e.cursor.Set(e.compCursor.Pos())
e.notifyAccepted()
}
}

Expand All @@ -359,7 +360,7 @@ func (e *Engine) ResetForce() {
// If the completion engine was not active to begin with, nothing will happen.
func (e *Engine) Reset() {
e.autoForce = false
if !e.IsActive() {
if !e.IsActive() && !e.IsInserting() {
e.ClearMenu(true)
return
}
Expand Down Expand Up @@ -399,6 +400,31 @@ func (e *Engine) IsInserting() bool {
return e.selected.Value != ""
}

// ConfirmSelection commits a selected confirmation-required candidate while
// leaving the input open for further editing. It reports whether it consumed
// the caller's accept-line action.
func (e *Engine) ConfirmSelection() bool {
if !e.IsInserting() || !e.selected.RequireConfirmation {
return false
}
e.Reset()
return true
}

// RequiresConfirmation reports whether the candidate that would be selected
// next must be explicitly confirmed.
func (e *Engine) RequiresConfirmation() bool {
return e.requiresConfirmation()
}

func (e *Engine) requiresConfirmation() bool {
if e.IsInserting() {
return e.selected.RequireConfirmation
}
group := e.currentGroup()
return group != nil && group.selected().RequireConfirmation
}

// Matches returns the number of completion candidates
// matching the current line/settings requirements.
func (e *Engine) Matches() int {
Expand Down
Loading