From e2f810048ec3e4bdcf4c57ae9cee87085236532f Mon Sep 17 00:00:00 2001 From: Kurtis Rader Date: Mon, 15 Mar 2021 20:52:51 -0700 Subject: [PATCH] Add $edit:after-command and $edit:command-duration The `edit:after-command` hooks are called with a single argument: a pseudo-map with these keys: "command": the command line that was run "duration": the execution duration in seconds "error": any error that occurred ($nil if no error occurred) The `edit:command-duration` variable is the elapsed seconds (as a float64) of the most recently run interactive command. Resolves #1029 --- 0.16.0-release-notes.md | 6 ++ pkg/edit/editor.go | 19 +++++- pkg/edit/{default_bindings.go => elv_init.go} | 15 ++++- pkg/edit/repl.go | 60 +++++++++++++++++++ pkg/edit/testutils_test.go | 3 +- pkg/eval/compile_effect.go | 12 +--- pkg/eval/eval.go | 10 +--- pkg/shell/editor.go | 9 +++ pkg/shell/interact.go | 22 +++++-- pkg/shell/script.go | 2 +- pkg/shell/shell.go | 8 ++- website/ref/edit.md | 48 ++++++++++----- 12 files changed, 164 insertions(+), 50 deletions(-) rename pkg/edit/{default_bindings.go => elv_init.go} (82%) create mode 100644 pkg/edit/repl.go diff --git a/0.16.0-release-notes.md b/0.16.0-release-notes.md index 2960c69a3..98551c5bd 100644 --- a/0.16.0-release-notes.md +++ b/0.16.0-release-notes.md @@ -69,3 +69,9 @@ New features in the interactive editor: - The editor now uses a DSL for filtering items in completion, history listing, location and navigation modes. + +- A new `edit:after-command` hook that is invoked after each interactive + command line is run. + +- A new `edit:command-duration` variable that is the number of seconds to + execute the most recent interactive command line. diff --git a/pkg/edit/editor.go b/pkg/edit/editor.go index 83b9351ad..e97f53598 100644 --- a/pkg/edit/editor.go +++ b/pkg/edit/editor.go @@ -24,6 +24,10 @@ type Editor struct { excMutex sync.RWMutex excList vals.List + + // Maybe move this to another type that represents the REPL cycle as a whole, not just the + // read/edit portion represented by the Editor type. + AfterCommand []func(src parse.Source, duration float64, err error) } // An interface that wraps notifyf and notifyError. It is only implemented by @@ -68,6 +72,7 @@ func NewEditor(tty cli.TTY, ev *eval.Evaler, st store.Store) *Editor { initInstant(ed, ev, nb) initMinibuf(ed, ev, nb) + initRepl(ed, ev, nb) initBufferBuiltins(ed.app, nb) initTTYBuiltins(ed.app, tty, nb) initMiscBuiltins(ed.app, nb) @@ -75,7 +80,7 @@ func NewEditor(tty cli.TTY, ev *eval.Evaler, st store.Store) *Editor { initStoreAPI(ed.app, nb, hs) ed.ns = nb.Ns() - evalDefaultBinding(ev, ed.ns) + initElvishState(ev, ed.ns) return ed } @@ -89,8 +94,9 @@ func initExceptionsAPI(ed *Editor, nb eval.NsBuilder) { nb.Add("exceptions", vars.FromPtrWithMutex(&ed.excList, &ed.excMutex)) } -func evalDefaultBinding(ev *eval.Evaler, ns *eval.Ns) { - src := parse.Source{Name: "[default bindings]", Code: defaultBindingsElv} +// Initialize the `edit` module by executing the pre-defined Elvish code for the module. +func initElvishState(ev *eval.Evaler, ns *eval.Ns) { + src := parse.Source{Name: "[RC file]", Code: elvInit} err := ev.Eval(src, eval.EvalCfg{Global: ns}) if err != nil { panic(err) @@ -102,6 +108,13 @@ func (ed *Editor) ReadCode() (string, error) { return ed.app.ReadCode() } +// RunAfterCommandHooks runs callbacks involving the interactive completion of a command line. +func (ed *Editor) RunAfterCommandHooks(src parse.Source, duration float64, err error) { + for _, f := range ed.AfterCommand { + f(src, duration, err) + } +} + // Ns returns a namespace for manipulating the editor from Elvish code. // // See https://elv.sh/ref/edit.html for the Elvish API. diff --git a/pkg/edit/default_bindings.go b/pkg/edit/elv_init.go similarity index 82% rename from pkg/edit/default_bindings.go rename to pkg/edit/elv_init.go index 8f87e27ea..2f68bd5d1 100644 --- a/pkg/edit/default_bindings.go +++ b/pkg/edit/elv_init.go @@ -1,7 +1,18 @@ package edit -// Elvish code for default bindings, assuming the editor ns as the global ns. -const defaultBindingsElv = ` +// Elvish code for default bindings, hooks, and similar initializations. This assumes the `edit` +// namespace as the global namespace. +const elvInit = ` +after-command = [ + # Capture the most recent interactive command duration in $edit:command-duration + # as a convenience for prompt functions. Note: The first time this is run is after + # shell.sourceRC() finishes so the initial value of command-duration is the time + # to execute the user's interactive configuration script. + [m]{ + command-duration = $m[duration] + } +] + global-binding = (binding-table [ &Ctrl-'['= $close-mode~ ]) diff --git a/pkg/edit/repl.go b/pkg/edit/repl.go new file mode 100644 index 000000000..ae50dcd82 --- /dev/null +++ b/pkg/edit/repl.go @@ -0,0 +1,60 @@ +package edit + +// This file encapsulates functionality related to a complete REPL cycle. Such as capturing +// information about the most recently executed interactive command. + +import ( + "src.elv.sh/pkg/eval" + "src.elv.sh/pkg/eval/vals" + "src.elv.sh/pkg/eval/vars" + "src.elv.sh/pkg/parse" +) + +//elvdoc:var after-command +// +// A list of functions to call after each interactive command completes. There is one pre-defined +// function used to populate the [`$edit:command-duration`](./edit.html#editcommand-duration) +// variable. Each function is called with a single [map](https://elv.sh/ref/language.html#map) +// argument containing the following keys: +// +// * `code`: A string containing the code that was executed. If `is-file` is true this is the +// content of the file that was sourced. +// +// * `duration`: A [floating-point number](https://elv.sh/ref/language.html#number) representing the +// command execution duration in seconds. +// +// * `error`: An [exception](../ref/language.html#exception) object if the command terminated with +// an exception, else [`$nil`](../ref/language.html#nil). +// +// * `name`: A string describing where the code originated; e.g., `[tty 1]` for the first +// interactive REPL cycle. If `is-file` is true this is the path of the file that was sourced. +// +// * `is-file`: False if the code was entered interactively. At the moment this is only true when +// this hook is run after sourcing the RC file. +// +// @cf edit:command-duration + +//elvdoc:var command-duration +// +// Duration, in seconds, of the most recent interactive command. This can be useful in your prompt +// to provide feedback on how long a command took to run. The initial value of this variable is the +// time to evaluate your *~/.elvish/rc.elv* script before printing the first prompt. +// +// @cf edit:after-command + +var commandDuration float64 + +func initRepl(ed *Editor, ev *eval.Evaler, nb eval.NsBuilder) { + // TODO: Change this to a read-only var, possibly by introducing a vars.FromPtrReadonly + // function, to guard against attempts to modify the value from Elvish code. + nb.Add("command-duration", vars.FromPtr(&commandDuration)) + + hook := newListVar(vals.EmptyList) + nb["after-command"] = hook + ed.AfterCommand = append(ed.AfterCommand, + func(src parse.Source, duration float64, err error) { + m := vals.MakeMap("name", src.Name, "code", src.Code, "is-file", src.IsFile, + "duration", duration, "error", err) + callHooks(ev, "$:after-command", hook.Get().(vals.List), m) + }) +} diff --git a/pkg/edit/testutils_test.go b/pkg/edit/testutils_test.go index 39b3fc925..0e00c6bfd 100644 --- a/pkg/edit/testutils_test.go +++ b/pkg/edit/testutils_test.go @@ -58,9 +58,8 @@ func setup(fns ...func(*fixture)) *fixture { tty, ttyCtrl := clitest.NewFakeTTY() ev := eval.NewEvaler() ed := NewEditor(tty, ev, st) - ev.AddModule("edit", ed.Ns()) + ev.AddBuiltin(eval.NsBuilder{}.AddNs("edit", ed.Ns()).Ns()) evals(ev, - `use edit`, // This is the same as the default prompt for non-root users. This makes // sure that the tests will work when run as root. "edit:prompt = { tilde-abbr $pwd; put '> ' }", diff --git a/pkg/eval/compile_effect.go b/pkg/eval/compile_effect.go index 3cc07a383..c7c655ad4 100644 --- a/pkg/eval/compile_effect.go +++ b/pkg/eval/compile_effect.go @@ -75,11 +75,6 @@ func (op *pipelineOp) exec(fm *Frame) Exception { fm.intCh = nil fm.background = true fm.Evaler.addNumBgJobs(1) - - if fm.Evaler.Editor() != nil { - // TODO: Redirect output in interactive mode so that the line - // editor does not get messed up. - } } nforms := len(op.subops) @@ -142,12 +137,7 @@ func (op *pipelineOp) exec(fm *Frame) Exception { msg += ", errors = " + err.Error() } if fm.Evaler.getNotifyBgJobSuccess() || err != nil { - editor := fm.Evaler.Editor() - if editor != nil { - editor.Notify("%s", msg) - } else { - fm.ErrorFile().WriteString(msg + "\n") - } + fm.ErrorFile().WriteString(msg + "\n") } }() return nil diff --git a/pkg/eval/eval.go b/pkg/eval/eval.go index c1ab117d1..99e15092e 100644 --- a/pkg/eval/eval.go +++ b/pkg/eval/eval.go @@ -81,13 +81,12 @@ type Evaler struct { // // TODO: Remove these dependency by providing more general extension points. daemonClient daemon.Client - editor Editor } // Editor is the interface that the line editor has to satisfy. It is needed so // that this package does not depend on the edit package. type Editor interface { - Notify(string, ...interface{}) + RunAfterCommandHooks(src parse.Source, duration float64, err error) } //elvdoc:var after-chdir @@ -346,13 +345,6 @@ func (ev *Evaler) DaemonClient() daemon.Client { return ev.daemonClient } -// Editor returns the editor associated with the Evaler. -func (ev *Evaler) Editor() Editor { - ev.mu.RLock() - defer ev.mu.RUnlock() - return ev.editor -} - // Chdir changes the current directory. On success it also updates the PWD // environment variable and records the new directory in the directory history. // It runs the functions in beforeChdir immediately before changing the diff --git a/pkg/shell/editor.go b/pkg/shell/editor.go index 2d8d3a94a..41d1bcb45 100644 --- a/pkg/shell/editor.go +++ b/pkg/shell/editor.go @@ -6,11 +6,15 @@ import ( "io" "os" + "src.elv.sh/pkg/parse" "src.elv.sh/pkg/strutil" ) +// This type is the interface that the line editor has to satisfy. It is needed so that this package +// does not depend on the edit package. type editor interface { ReadCode() (string, error) + RunAfterCommandHooks(src parse.Source, duration float64, err error) } type minEditor struct { @@ -22,6 +26,11 @@ func newMinEditor(in, out *os.File) *minEditor { return &minEditor{bufio.NewReader(in), out} } +// RunAfterCommandHooks is a no-op in the minimum editor since it doesn't support +// `edit:after-command` hooks. The method is needed to satisfy the `editor` interface. +func (ed *minEditor) RunAfterCommandHooks(src parse.Source, duration float64, err error) { +} + func (ed *minEditor) ReadCode() (string, error) { wd, err := os.Getwd() if err != nil { diff --git a/pkg/shell/interact.go b/pkg/shell/interact.go index 9cae7ccd9..6a6afe776 100644 --- a/pkg/shell/interact.go +++ b/pkg/shell/interact.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "strings" "syscall" "time" @@ -60,7 +61,7 @@ func Interact(fds [3]*os.File, cfg *InteractConfig) { // Source rc.elv. if cfg.Paths.Rc != "" { - err := sourceRC(fds, ev, cfg.Paths.Rc) + err := sourceRC(fds, ev, ed, cfg.Paths.Rc) if err != nil { diag.ShowError(fds[2], err) } @@ -75,7 +76,6 @@ func Interact(fds [3]*os.File, cfg *InteractConfig) { cmdNum++ line, err := ed.ReadCode() - if err == io.EOF { break } else if err != nil { @@ -97,8 +97,15 @@ func Interact(fds [3]*os.File, cfg *InteractConfig) { // No error; reset cooldown. cooldown = time.Second - err = evalInTTY(ev, fds, - parse.Source{Name: fmt.Sprintf("[tty %v]", cmdNum), Code: line}) + // Execute the command line only if it is not entirely whitespace. This keeps side-effects, + // such as executing `$edit:after-command` hooks, from occurring when we didn't actually + // evaluate any code entered by the user. + if strings.TrimSpace(line) == "" { + continue + } + src := parse.Source{Name: fmt.Sprintf("[tty %v]", cmdNum), Code: line} + duration, err := evalInTTY(ev, fds, src) + ed.RunAfterCommandHooks(src, duration, err) term.Sanitize(fds[0], fds[2]) if err != nil { diag.ShowError(fds[2], err) @@ -106,7 +113,7 @@ func Interact(fds [3]*os.File, cfg *InteractConfig) { } } -func sourceRC(fds [3]*os.File, ev *eval.Evaler, rcPath string) error { +func sourceRC(fds [3]*os.File, ev *eval.Evaler, ed eval.Editor, rcPath string) error { absPath, err := filepath.Abs(rcPath) if err != nil { return fmt.Errorf("cannot get full path of rc.elv: %v", err) @@ -118,5 +125,8 @@ func sourceRC(fds [3]*os.File, ev *eval.Evaler, rcPath string) error { } return err } - return evalInTTY(ev, fds, parse.Source{Name: absPath, Code: code, IsFile: true}) + src := parse.Source{Name: absPath, Code: code, IsFile: true} + duration, err := evalInTTY(ev, fds, src) + ed.RunAfterCommandHooks(src, duration, err) + return err } diff --git a/pkg/shell/script.go b/pkg/shell/script.go index daae5cebc..47430c515 100644 --- a/pkg/shell/script.go +++ b/pkg/shell/script.go @@ -67,7 +67,7 @@ func Script(fds [3]*os.File, args []string, cfg *ScriptConfig) int { return 2 } } else { - err := evalInTTY(ev, fds, src) + _, err := evalInTTY(ev, fds, src) if err != nil { diag.ShowError(fds[2], err) return 2 diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index dd57d99cb..36219dbe2 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -5,6 +5,7 @@ import ( "os" "os/signal" "strconv" + "time" "src.elv.sh/pkg/cli/term" "src.elv.sh/pkg/env" @@ -62,11 +63,14 @@ func setupShell(fds [3]*os.File, p Paths, spawn bool) (*eval.Evaler, func()) { } } -func evalInTTY(ev *eval.Evaler, fds [3]*os.File, src parse.Source) error { +func evalInTTY(ev *eval.Evaler, fds [3]*os.File, src parse.Source) (float64, error) { + start := time.Now() ports, cleanup := eval.PortsFromFiles(fds, ev.ValuePrefix()) defer cleanup() - return ev.Eval(src, eval.EvalCfg{ + err := ev.Eval(src, eval.EvalCfg{ Ports: ports, Interrupt: eval.ListenInterrupts, PutInFg: true}) + end := time.Now() + return end.Sub(start).Seconds(), err } func incSHLVL() func() { diff --git a/website/ref/edit.md b/website/ref/edit.md index 76c3597b8..b008c536e 100644 --- a/website/ref/edit.md +++ b/website/ref/edit.md @@ -431,28 +431,48 @@ hence that candidates for all completion types are matched by prefix. ## Hooks -Hooks are functions that are executed at certain points in time. In Elvish, this -functionality is provided by lists of functions. - -There are current two hooks: - -- `$edit:before-readline`, whose elements are called before the editor reads - code, with no arguments. - -- `$edit:after-readline`, whose elements are called, after the editor reads - code, with a sole element -- the line just read. +Hooks are functions that are executed at certain points in time. In Elvish this +functionality is provided by variables that are a list of functions. Some hooks +are populated with one or more functions when Elvish starts. In general you +should append to a hook variable rather than assign a list of functions to it. +That is, rather than doing `hook-var = [ []{ put 'I ran' } ]` you should do +`hook-var = [ $@hook-var []{ put 'I ran' } ]`. + +These are the editor/REPL hooks: + +- [`$edit:before-readline`](https://elv.sh/ref/edit.html#editbefore-readline): + The functions are called before the editor runs. Each function is called + with no arguments. + +- [`$edit:after-readline`](https://elv.sh/ref/edit.html#editafter-readline): + The functions are called after the editor accepts a command for execution. + Each function is called with a sole argument: the line just read. + +- [`$edit:after-command`](https://elv.sh/ref/edit.html#editafter-command): The + functions are called after the shell executes the command you entered + (typically by pressing the `Enter` key). Each function is called with a sole + argument: a map that provides information about the executed command. This + hook is also called after your interactive RC file is executed and before + the first prompt is output. Example usage: ```elvish edit:before-readline = [{ echo 'going to read' }] edit:after-readline = [[line]{ echo 'just read '$line }] +edit:after-command = [[m]{ echo 'command took '$m[duration]' seconds' }] ``` -Then every time you accept a chunk of code (and thus leaving the editor), -`just read` followed by the code is printed; and at the very beginning of an -Elvish session, or after a chunk of code is executed, `going to read` is -printed. +Given the above hooks... + +1. Every time you accept a chunk of code (normally by pressing Enter) + `just read` is printed. + +1. At the very beginning of an Elvish session, or after a chunk of code is + handled, `going to read` is printed. + +1. After each non empty chunk of code is accepted and executed the string + "command took ... seconds` is output. ## Word types