Skip to content

Commit

Permalink
interp: add ExecHandlers to support exec middlewares
Browse files Browse the repository at this point in the history
The tests and examples were already using a form of middlewares.
For example, ExampleExecHandler would handle some specific cases,
and fall back to DefaultExecHandler.

However, this fall back was hard-coded to DefaultExecHandler.
The function wasn't a reusable middleware because of that.

Instead, borrow the design of middlewares from go-chi:

	func (mx *Mux) Use(middlewares ...func(http.Handler) http.Handler)

In such an API, each middleware is a function which takes "next",
the next handler, and returns its own handler.
This way, each middleware can choose whether to handle all calls,
or just some of them - while passing on the rest to "next".

This makes our API more flexible and our tests less awkward.
Most importantly, it enables #93, as a coreutils ExecHandler by design
will only be able to handle some coreutil commands and nothing else.

For #93.
  • Loading branch information
mvdan committed Jan 22, 2023
1 parent 698a968 commit 27fa5db
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 85 deletions.
66 changes: 59 additions & 7 deletions interp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,15 @@ type Runner struct {
// arguments. It may be nil.
callHandler CallHandlerFunc

// execHandler is a function responsible for executing programs. It must be non-nil.
// execHandler is responsible for executing programs. It must not be nil.
execHandler ExecHandlerFunc

// openHandler is a function responsible for opening files. It must be non-nil.
// execMiddlewares grows with calls to ExecHandlers,
// and is used to construct execHandler when Reset is first called.
// The slice is needed to preserve the relative order of middlewares.
execMiddlewares []func(ExecHandlerFunc) ExecHandlerFunc

// openHandler is a function responsible for opening files. It must not be nil.
openHandler OpenHandlerFunc

// readDirHandler is a function responsible for reading directories during
Expand Down Expand Up @@ -181,7 +186,6 @@ func (r *Runner) optByFlag(flag byte) *bool {
func New(opts ...RunnerOption) (*Runner, error) {
r := &Runner{
usedNew: true,
execHandler: DefaultExecHandler(2 * time.Second),
openHandler: DefaultOpenHandler(),
readDirHandler: DefaultReadDirHandler(),
statHandler: DefaultStatHandler(),
Expand Down Expand Up @@ -213,9 +217,11 @@ func New(opts ...RunnerOption) (*Runner, error) {
return r, nil
}

// RunnerOption is a function which can be passed to New to alter Runner behaviour.
// To apply option to existing Runner call it directly,
// for example interp.Params("-e")(runner).
// RunnerOption can be passed to New to alter a Runner's behaviour.
// It can also be applied directly on an existing Runner,
// such as interp.Params("-e")(runner).
// Note that options cannot be applied once Run or Reset have been called.
// TODO: enforce that rule via didReset.
type RunnerOption func(*Runner) error

// Env sets the interpreter's environment. If nil, a copy of the current
Expand Down Expand Up @@ -329,14 +335,46 @@ func CallHandler(f CallHandlerFunc) RunnerOption {
}
}

// ExecHandler sets the command execution handler. See ExecHandlerFunc for more info.
// ExecHandler sets one command execution handler,
// which replaces DefaultExecHandler(2 * time.Second).
//
// Deprecated: use ExecHandlers instead, which allows for middleware handlers.
func ExecHandler(f ExecHandlerFunc) RunnerOption {
return func(r *Runner) error {
r.execHandler = f
return nil
}
}

// ExecHandlers appends middlewares to handle command execution.
// The middlewares are chained from first to last, and the first is called by the runner.
// Each middleware is expected to call the "next" middleware at most once.
//
// For example, a middleware may implement only some commands.
// For those commands, it can run its logic and avoid calling "next".
// For any other commands, it can call "next" with the original parameters.
//
// Another common example is a middleware which always calls "next",
// but runs custom logic either before or after that call.
// For instance, a middleware could change the arguments to the "next" call,
// or it could print log lines before or after the call to "next".
//
// The last exec handler is DefaultExecHandler(2 * time.Second).
func ExecHandlers(middlewares ...func(next ExecHandlerFunc) ExecHandlerFunc) RunnerOption {
return func(r *Runner) error {
r.execMiddlewares = append(r.execMiddlewares, middlewares...)
return nil
}
}

// TODO: consider porting the middleware API in ExecHandlers to OpenHandler,
// ReadDirHandler, and StatHandler.

// TODO(v4): now that ExecHandlers allows calling a next handler with changed
// arguments, one of the two advantages of CallHandler is gone. The other is the
// ability to work with builtins; if we make ExecHandlers work with builtins, we
// could join both APIs.

// OpenHandler sets file open handler. See OpenHandlerFunc for more info.
func OpenHandler(f OpenHandlerFunc) RunnerOption {
return func(r *Runner) error {
Expand Down Expand Up @@ -561,6 +599,19 @@ func (r *Runner) Reset() {
r.origStdin = r.stdin
r.origStdout = r.stdout
r.origStderr = r.stderr

if r.execHandler != nil && len(r.execMiddlewares) > 0 {
panic("interp.ExecHandler should be replaced with interp.ExecHandlers, not mixed")
}
if r.execHandler == nil {
r.execHandler = DefaultExecHandler(2 * time.Second)
}
// Middlewares are chained from first to last, and each can call the
// next in the chain, so we need to construct the chain backwards.
for i := len(r.execMiddlewares) - 1; i >= 0; i-- {
middleware := r.execMiddlewares[i]
r.execHandler = middleware(r.execHandler)
}
}
// reset the internal state
*r = Runner{
Expand Down Expand Up @@ -632,6 +683,7 @@ func (r *Runner) Reset() {
r.setVarString("OPTIND", "1")

r.dirStack = append(r.dirStack, r.Dir)

r.didReset = true
}

Expand Down
34 changes: 19 additions & 15 deletions interp/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"os"
"runtime"
"strings"
"time"

"mvdan.cc/sh/v3/expand"
"mvdan.cc/sh/v3/interp"
Expand Down Expand Up @@ -38,28 +37,33 @@ func Example() {
// global_value
}

func ExampleExecHandler() {
func ExampleExecHandlers() {
src := "echo foo; join ! foo bar baz; missing-program bar"
file, _ := syntax.NewParser().Parse(strings.NewReader(src), "")

exec := func(ctx context.Context, args []string) error {
hc := interp.HandlerCtx(ctx)

if args[0] == "join" {
fmt.Fprintln(hc.Stdout, strings.Join(args[2:], args[1]))
return nil
execJoin := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
return func(ctx context.Context, args []string) error {
hc := interp.HandlerCtx(ctx)
if args[0] == "join" {
fmt.Fprintln(hc.Stdout, strings.Join(args[2:], args[1]))
return nil
}
return next(ctx, args)
}

if _, err := interp.LookPathDir(hc.Dir, hc.Env, args[0]); err != nil {
fmt.Printf("%s is not installed\n", args[0])
return interp.NewExitStatus(1)
}
execNotInstalled := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
return func(ctx context.Context, args []string) error {
hc := interp.HandlerCtx(ctx)
if _, err := interp.LookPathDir(hc.Dir, hc.Env, args[0]); err != nil {
fmt.Printf("%s is not installed\n", args[0])
return interp.NewExitStatus(1)
}
return next(ctx, args)
}

return interp.DefaultExecHandler(2*time.Second)(ctx, args)
}
runner, _ := interp.New(
interp.StdIO(nil, os.Stdout, os.Stdout),
interp.ExecHandler(exec),
interp.ExecHandlers(execJoin, execNotInstalled),
)
runner.Run(context.TODO(), file)
// Output:
Expand Down
Loading

0 comments on commit 27fa5db

Please sign in to comment.