Skip to content

Commit

Permalink
Add $edit:after-command and $edit:command-duration
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Kurtis Rader authored and xiaq committed May 5, 2021
1 parent 83ce836 commit e2f8100
Show file tree
Hide file tree
Showing 12 changed files with 164 additions and 50 deletions.
6 changes: 6 additions & 0 deletions 0.16.0-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 16 additions & 3 deletions pkg/edit/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,14 +72,15 @@ 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)
initStateAPI(ed.app, nb)
initStoreAPI(ed.app, nb, hs)

ed.ns = nb.Ns()
evalDefaultBinding(ev, ed.ns)
initElvishState(ev, ed.ns)

return ed
}
Expand All @@ -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)
Expand All @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions pkg/edit/default_bindings.go → pkg/edit/elv_init.go
Original file line number Diff line number Diff line change
@@ -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~
])
Expand Down
60 changes: 60 additions & 0 deletions pkg/edit/repl.go
Original file line number Diff line number Diff line change
@@ -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, "$<edit>:after-command", hook.Get().(vals.List), m)
})
}
3 changes: 1 addition & 2 deletions pkg/edit/testutils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 '> ' }",
Expand Down
12 changes: 1 addition & 11 deletions pkg/eval/compile_effect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 1 addition & 9 deletions pkg/eval/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions pkg/shell/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
22 changes: 16 additions & 6 deletions pkg/shell/interact.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"syscall"
"time"

Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand All @@ -97,16 +97,23 @@ 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)
}
}
}

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)
Expand All @@ -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
}
2 changes: 1 addition & 1 deletion pkg/shell/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions pkg/shell/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/signal"
"strconv"
"time"

"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/env"
Expand Down Expand Up @@ -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() {
Expand Down
48 changes: 34 additions & 14 deletions website/ref/edit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit e2f8100

Please sign in to comment.