浏览代码

Implement edit:command-abbr

It turns out that the "small word" abbreviation mechanism I added isn't
really what I, or most users, want. What users really want, at least
most of the time, is the Fish shell abbreviation behavior of expanding
an abbreviation only in the command head position.

Resolves #1472
Kurtis Rader 2 年之前
父节点
当前提交
fa704d6ac6
共有 6 个文件被更改,包括 157 次插入43 次删除
  1. 4 1
      0.19.0-release-notes.md
  2. 10 9
      pkg/cli/app.go
  3. 2 1
      pkg/cli/app_spec.go
  4. 66 13
      pkg/cli/tk/codearea.go
  5. 36 6
      pkg/cli/tk/codearea_test.go
  6. 39 13
      pkg/edit/insert_api.go

+ 4 - 1
0.19.0-release-notes.md

@@ -1,5 +1,5 @@
 This is the draft release notes for 0.19.0, scheduled to be released around
-2020-07-01.
+2022-07-01.
 
 # Breaking changes
 
@@ -26,3 +26,6 @@ and compiled, even if it is not executed:
     the Go float64 type is the only underlying inexact number type for now. Its
     behavior may change in future if there are more underlying types for inexact
     numbers.
+
+-   A new type of interactive abbreviation: `edit:command-abbr`
+    ([#1472](https://b.elv.sh/1472)).

+ 10 - 9
pkg/cli/app.go

@@ -130,15 +130,16 @@ func NewApp(spec AppSpec) App {
 	lp.RedrawCb(a.redraw)
 
 	a.codeArea = tk.NewCodeArea(tk.CodeAreaSpec{
-		Bindings:      spec.CodeAreaBindings,
-		Highlighter:   a.Highlighter.Get,
-		Prompt:        a.Prompt.Get,
-		RPrompt:       a.RPrompt.Get,
-		Abbreviations: spec.Abbreviations,
-		QuotePaste:    spec.QuotePaste,
-		OnSubmit:      a.CommitCode,
-		State:         spec.CodeAreaState,
-
+		Bindings:    spec.CodeAreaBindings,
+		Highlighter: a.Highlighter.Get,
+		Prompt:      a.Prompt.Get,
+		RPrompt:     a.RPrompt.Get,
+		QuotePaste:  spec.QuotePaste,
+		OnSubmit:    a.CommitCode,
+		State:       spec.CodeAreaState,
+
+		SimpleAbbreviations:    spec.SimpleAbbreviations,
+		CommandAbbreviations:   spec.CommandAbbreviations,
 		SmallWordAbbreviations: spec.SmallWordAbbreviations,
 	})
 

+ 2 - 1
pkg/cli/app_spec.go

@@ -19,9 +19,10 @@ type AppSpec struct {
 
 	GlobalBindings   tk.Bindings
 	CodeAreaBindings tk.Bindings
-	Abbreviations    func(f func(abbr, full string))
 	QuotePaste       func() bool
 
+	SimpleAbbreviations    func(f func(abbr, full string))
+	CommandAbbreviations   func(f func(abbr, full string))
 	SmallWordAbbreviations func(f func(abbr, full string))
 
 	CodeAreaState tk.CodeAreaState

+ 66 - 13
pkg/cli/tk/codearea.go

@@ -2,6 +2,7 @@ package tk
 
 import (
 	"bytes"
+	"regexp"
 	"strings"
 	"sync"
 	"unicode"
@@ -36,9 +37,10 @@ type CodeAreaSpec struct {
 	// Right-prompt callback.
 	RPrompt func() ui.Text
 	// A function that calls the callback with string pairs for abbreviations
-	// and their expansions. If this function is not given, the Widget does not
-	// expand any abbreviations.
-	Abbreviations          func(f func(abbr, full string))
+	// and their expansions. If no function is provided the Widget does not
+	// expand any abbreviations of the specified type.
+	SimpleAbbreviations    func(f func(abbr, full string))
+	CommandAbbreviations   func(f func(abbr, full string))
 	SmallWordAbbreviations func(f func(abbr, full string))
 	// A function that returns whether pasted texts (from bracketed pastes)
 	// should be quoted. If this function is not given, the Widget defaults to
@@ -123,8 +125,11 @@ func NewCodeArea(spec CodeAreaSpec) CodeArea {
 	if spec.RPrompt == nil {
 		spec.RPrompt = func() ui.Text { return nil }
 	}
-	if spec.Abbreviations == nil {
-		spec.Abbreviations = func(func(a, f string)) {}
+	if spec.SimpleAbbreviations == nil {
+		spec.SimpleAbbreviations = func(func(a, f string)) {}
+	}
+	if spec.CommandAbbreviations == nil {
+		spec.CommandAbbreviations = func(func(a, f string)) {}
 	}
 	if spec.SmallWordAbbreviations == nil {
 		spec.SmallWordAbbreviations = func(func(a, f string)) {}
@@ -208,12 +213,11 @@ func (w *codeArea) handlePasteSetting(start bool) bool {
 	return true
 }
 
-// Tries to expand a simple abbreviation. This function assumes that the state
-// mutex is already being held.
+// Tries to expand a simple abbreviation. This function assumes the state mutex is held.
 func (w *codeArea) expandSimpleAbbr() {
 	var abbr, full string
 	// Find the longest matching abbreviation.
-	w.Abbreviations(func(a, f string) {
+	w.SimpleAbbreviations(func(a, f string) {
 		if strings.HasSuffix(w.inserts, a) && len(a) > len(abbr) {
 			abbr, full = a, f
 		}
@@ -228,12 +232,58 @@ func (w *codeArea) expandSimpleAbbr() {
 	}
 }
 
-// Tries to expand a word abbreviation. This function assumes that the state
-// mutex is already being held.
-func (w *codeArea) expandWordAbbr(trigger rune, categorizer func(rune) int) {
+// Try to expand a command abbreviation. This function assumes the state mutex is held.
+//
+// We use a regex rather than parse.Parse() because dealing with the the latter requires a lot of
+// code. A simple regex is far simpler and good enough for this use case. The regex essentially
+// matches commands at the start of the line (with potential leading whitespace) and similarly after
+// the opening brace of a lambda or pipeline char. There are two corner cases it doesn't handle:
+//
+// 1) It doesn't handle a caret followed by a newline if the token on the last line would otherwise
+// be treated as a command given what preceded the caret.
+//
+// 2) It only handles bareword commands.
+var commandRegex = regexp.MustCompile(`(?:^|\||;|{)\s*([\p{L}\p{Nd}!%+,-./:@\_]+)\s\z`)
+
+func (w *codeArea) expandCommandAbbr() {
+	code := &w.State.Buffer
+	if code.Dot < len(code.Content) {
+		// Command abbreviations are only expanded when inserting at the end of the buffer.
+		return
+	}
+
+	// See if there is something that looks like a bareword at the end of the buffer.
+	matches := commandRegex.FindSubmatch([]byte(code.Content))
+	if len(matches) == 0 {
+		return
+	}
+
+	// Find an abbreviation matching the command.
+	command := string(matches[1])
+	var expansion string
+	w.CommandAbbreviations(func(a, e string) {
+		if a == command {
+			expansion = e
+		}
+	})
+	if len(expansion) == 0 {
+		return
+	}
+
+	// We found a matching abbreviation -- replace it with its expansion.
+	newContent := code.Content[:code.Dot-len(command)-1] + expansion + " "
+	*code = CodeBuffer{
+		Content: newContent,
+		Dot:     len(newContent),
+	}
+	w.resetInserts()
+}
+
+// Try to expand a small word abbreviation. This function assumes the state mutex is held.
+func (w *codeArea) expandSmallWordAbbr(trigger rune, categorizer func(rune) int) {
 	c := &w.State.Buffer
 	if c.Dot < len(c.Content) {
-		// Word abbreviations are only expanded at the end of the buffer.
+		// Word abbreviations are only expanded when inserting at the end of the buffer.
 		return
 	}
 	triggerLen := len(string(trigger))
@@ -333,8 +383,11 @@ func (w *codeArea) handleKeyEvent(key ui.Key) bool {
 		w.State.Buffer.InsertAtDot(s)
 		w.inserts += s
 		w.lastCodeBuffer = w.State.Buffer
+		if key.Rune == ' ' {
+			w.expandCommandAbbr()
+		}
 		w.expandSimpleAbbr()
-		w.expandWordAbbr(key.Rune, CategorizeSmallWord)
+		w.expandSmallWordAbbr(key.Rune, CategorizeSmallWord)
 		return true
 	}
 }

+ 36 - 6
pkg/cli/tk/codearea_test.go

@@ -276,7 +276,7 @@ var codeAreaHandleTests = []handleTest{
 	{
 		Name: "abbreviation expansion",
 		Given: NewCodeArea(CodeAreaSpec{
-			Abbreviations: func(f func(abbr, full string)) {
+			SimpleAbbreviations: func(f func(abbr, full string)) {
 				f("dn", "/dev/null")
 			},
 		}),
@@ -286,7 +286,7 @@ var codeAreaHandleTests = []handleTest{
 	{
 		Name: "abbreviation expansion 2",
 		Given: NewCodeArea(CodeAreaSpec{
-			Abbreviations: func(f func(abbr, full string)) {
+			SimpleAbbreviations: func(f func(abbr, full string)) {
 				f("||", " | less")
 			},
 		}),
@@ -296,7 +296,7 @@ var codeAreaHandleTests = []handleTest{
 	{
 		Name: "abbreviation expansion after other content",
 		Given: NewCodeArea(CodeAreaSpec{
-			Abbreviations: func(f func(abbr, full string)) {
+			SimpleAbbreviations: func(f func(abbr, full string)) {
 				f("||", " | less")
 			},
 		}),
@@ -306,7 +306,7 @@ var codeAreaHandleTests = []handleTest{
 	{
 		Name: "abbreviation expansion preferring longest",
 		Given: NewCodeArea(CodeAreaSpec{
-			Abbreviations: func(f func(abbr, full string)) {
+			SimpleAbbreviations: func(f func(abbr, full string)) {
 				f("n", "none")
 				f("dn", "/dev/null")
 			},
@@ -317,7 +317,7 @@ var codeAreaHandleTests = []handleTest{
 	{
 		Name: "abbreviation expansion interrupted by function key",
 		Given: NewCodeArea(CodeAreaSpec{
-			Abbreviations: func(f func(abbr, full string)) {
+			SimpleAbbreviations: func(f func(abbr, full string)) {
 				f("dn", "/dev/null")
 			},
 		}),
@@ -365,6 +365,36 @@ var codeAreaHandleTests = []handleTest{
 			term.K('h'), term.K(' ')},
 		WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "gh ", Dot: 3}},
 	},
+	{
+		Name: "command abbreviation expansion",
+		Given: NewCodeArea(CodeAreaSpec{
+			CommandAbbreviations: func(f func(abbr, full string)) {
+				f("eh", "echo hello")
+			},
+		}),
+		Events:       []term.Event{term.K('e'), term.K('h'), term.K(' ')},
+		WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "echo hello ", Dot: 11}},
+	},
+	{
+		Name: "command abbreviation expansion not at start of line",
+		Given: NewCodeArea(CodeAreaSpec{
+			CommandAbbreviations: func(f func(abbr, full string)) {
+				f("eh", "echo hello")
+			},
+		}),
+		Events:       []term.Event{term.K('x'), term.K('|'), term.K('e'), term.K('h'), term.K(' ')},
+		WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "x|echo hello ", Dot: 13}},
+	},
+	{
+		Name: "no command abbreviation expansion when not in command position",
+		Given: NewCodeArea(CodeAreaSpec{
+			CommandAbbreviations: func(f func(abbr, full string)) {
+				f("eh", "echo hello")
+			},
+		}),
+		Events:       []term.Event{term.K('x'), term.K(' '), term.K('e'), term.K('h'), term.K(' ')},
+		WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "x eh ", Dot: 5}},
+	},
 	{
 		Name: "key bindings",
 		Given: NewCodeArea(CodeAreaSpec{Bindings: MapBindings{
@@ -411,7 +441,7 @@ func TestCodeArea_Handle_UnhandledEvents(t *testing.T) {
 
 func TestCodeArea_Handle_AbbreviationExpansionInterruptedByExternalMutation(t *testing.T) {
 	w := NewCodeArea(CodeAreaSpec{
-		Abbreviations: func(f func(abbr, full string)) {
+		SimpleAbbreviations: func(f func(abbr, full string)) {
 			f("dn", "/dev/null")
 		},
 	})

+ 39 - 13
pkg/edit/insert_api.go

@@ -9,7 +9,7 @@ import (
 
 //elvdoc:var abbr
 //
-// A map from (simple) abbreviations to their expansions.
+// A map from simple abbreviations to their expansions.
 //
 // An abbreviation is replaced by its expansion when it is typed in full
 // and consecutively, without being interrupted by the use of other editing
@@ -29,11 +29,31 @@ import (
 // the cursor left, and typing another `|` does **not** expand to `| less`,
 // since the abbreviation `||` was not typed consecutively.
 //
-// @cf edit:small-word-abbr
+// @cf edit:command-abbr edit:small-word-abbr
+
+//elvdoc:var command-abbr
+//
+// A map from command abbreviations to their expansions.
+//
+// A command abbreviation is replaced by its expansion when it is typed in full and consecutively at
+// the end of the line, without being interrupted by the use of other editing functionalities, such
+// as cursor movements. It is only expanded when the abbreviation is seen in the command position
+// followed by a space. This is similar to Fish shell abbreviations but does not trigger the
+// expansion when pressing Enter -- you must type a space first.
+//
+// Examples:
+//
+// ```elvish
+// set edit:command-abbr['l'] = 'less'
+// set edit:command-abbr['gc'] = 'git commit'
+// ```
+//
+// @cf edit:abbr edit:small-word-abbr
 
 //elvdoc:var small-word-abbr
 //
-// A map from small-word abbreviations and their expansions.
+// A map from small-word abbreviations to their expansions. Note that you probably want to create
+// [command abbreviation](#command-abbr) rather than a small-word abbreviation.
 //
 // A small-word abbreviation is replaced by its expansion after it is typed in
 // full and consecutively, and followed by another character (the *trigger*
@@ -49,7 +69,8 @@ import (
 //
 // -   The cursor must be at the end of the buffer.
 //
-// If more than one abbreviations would match, the longest one is used.
+// If more than one abbreviations would match, the longest one is used. See the description of
+// [small words](#word-types) for more information.
 //
 // As an example, with the following configuration:
 //
@@ -99,16 +120,20 @@ import (
 // If both a [simple abbreviation](#edit:abbr) and a small-word abbreviation can
 // be expanded, the simple abbreviation has priority.
 //
-// @cf edit:abbr
+// @cf edit:abbr edit:command-abbr
 
 func initInsertAPI(appSpec *cli.AppSpec, nt notifier, ev *eval.Evaler, nb eval.NsBuilder) {
-	abbr := vals.EmptyMap
-	abbrVar := vars.FromPtr(&abbr)
-	appSpec.Abbreviations = makeMapIterator(abbrVar)
+	simpleAbbr := vals.EmptyMap
+	simpleAbbrVar := vars.FromPtr(&simpleAbbr)
+	appSpec.SimpleAbbreviations = makeMapIterator(simpleAbbrVar)
+
+	commandAbbr := vals.EmptyMap
+	commandAbbrVar := vars.FromPtr(&commandAbbr)
+	appSpec.CommandAbbreviations = makeMapIterator(commandAbbrVar)
 
-	SmallWordAbbr := vals.EmptyMap
-	SmallWordAbbrVar := vars.FromPtr(&SmallWordAbbr)
-	appSpec.SmallWordAbbreviations = makeMapIterator(SmallWordAbbrVar)
+	smallWordAbbr := vals.EmptyMap
+	smallWordAbbrVar := vars.FromPtr(&smallWordAbbr)
+	appSpec.SmallWordAbbreviations = makeMapIterator(smallWordAbbrVar)
 
 	bindingVar := newBindingVar(emptyBindingsMap)
 	appSpec.CodeAreaBindings = newMapBindings(nt, ev, bindingVar)
@@ -120,8 +145,9 @@ func initInsertAPI(appSpec *cli.AppSpec, nt notifier, ev *eval.Evaler, nb eval.N
 		quotePaste.Set(!quotePaste.Get().(bool))
 	}
 
-	nb.AddVar("abbr", abbrVar)
-	nb.AddVar("small-word-abbr", SmallWordAbbrVar)
+	nb.AddVar("abbr", simpleAbbrVar)
+	nb.AddVar("command-abbr", commandAbbrVar)
+	nb.AddVar("small-word-abbr", smallWordAbbrVar)
 	nb.AddGoFn("toggle-quote-paste", toggleQuotePaste)
 	nb.AddNs("insert", eval.BuildNs().
 		AddVar("binding", bindingVar).