• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2022 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5// Package script implements a small, customizable, platform-agnostic scripting
6// language.
7//
8// Scripts are run by an [Engine] configured with a set of available commands
9// and conditions that guard those commands. Each script has an associated
10// working directory and environment, along with a buffer containing the stdout
11// and stderr output of a prior command, tracked in a [State] that commands can
12// inspect and modify.
13//
14// The default commands configured by [NewEngine] resemble a simplified Unix
15// shell.
16//
17// # Script Language
18//
19// Each line of a script is parsed into a sequence of space-separated command
20// words, with environment variable expansion within each word and # marking an
21// end-of-line comment. Additional variables named ':' and '/' are expanded
22// within script arguments (expanding to the value of os.PathListSeparator and
23// os.PathSeparator respectively) but are not inherited in subprocess
24// environments.
25//
26// Adding single quotes around text keeps spaces in that text from being treated
27// as word separators and also disables environment variable expansion.
28// Inside a single-quoted block of text, a repeated single quote indicates
29// a literal single quote, as in:
30//
31//	'Don''t communicate by sharing memory.'
32//
33// A line beginning with # is a comment and conventionally explains what is
34// being done or tested at the start of a new section of the script.
35//
36// Commands are executed one at a time, and errors are checked for each command;
37// if any command fails unexpectedly, no subsequent commands in the script are
38// executed. The command prefix ! indicates that the command on the rest of the
39// line (typically go or a matching predicate) must fail instead of succeeding.
40// The command prefix ? indicates that the command may or may not succeed, but
41// the script should continue regardless.
42//
43// The command prefix [cond] indicates that the command on the rest of the line
44// should only run when the condition is satisfied.
45//
46// A condition can be negated: [!root] means to run the rest of the line only if
47// the user is not root. Multiple conditions may be given for a single command,
48// for example, '[linux] [amd64] skip'. The command will run if all conditions
49// are satisfied.
50package script
51
52import (
53	"bufio"
54	"context"
55	"errors"
56	"fmt"
57	"io"
58	"sort"
59	"strings"
60	"time"
61)
62
63// An Engine stores the configuration for executing a set of scripts.
64//
65// The same Engine may execute multiple scripts concurrently.
66type Engine struct {
67	Cmds  map[string]Cmd
68	Conds map[string]Cond
69
70	// If Quiet is true, Execute deletes log prints from the previous
71	// section when starting a new section.
72	Quiet bool
73}
74
75// NewEngine returns an Engine configured with a basic set of commands and conditions.
76func NewEngine() *Engine {
77	return &Engine{
78		Cmds:  DefaultCmds(),
79		Conds: DefaultConds(),
80	}
81}
82
83// A Cmd is a command that is available to a script.
84type Cmd interface {
85	// Run begins running the command.
86	//
87	// If the command produces output or can be run in the background, run returns
88	// a WaitFunc that will be called to obtain the result of the command and
89	// update the engine's stdout and stderr buffers.
90	//
91	// Run itself and the returned WaitFunc may inspect and/or modify the State,
92	// but the State's methods must not be called concurrently after Run has
93	// returned.
94	//
95	// Run may retain and access the args slice until the WaitFunc has returned.
96	Run(s *State, args ...string) (WaitFunc, error)
97
98	// Usage returns the usage for the command, which the caller must not modify.
99	Usage() *CmdUsage
100}
101
102// A WaitFunc is a function called to retrieve the results of a Cmd.
103type WaitFunc func(*State) (stdout, stderr string, err error)
104
105// A CmdUsage describes the usage of a Cmd, independent of its name
106// (which can change based on its registration).
107type CmdUsage struct {
108	Summary string   // in the style of the Name section of a Unix 'man' page, omitting the name
109	Args    string   // a brief synopsis of the command's arguments (only)
110	Detail  []string // zero or more sentences in the style of the Description section of a Unix 'man' page
111
112	// If Async is true, the Cmd is meaningful to run in the background, and its
113	// Run method must return either a non-nil WaitFunc or a non-nil error.
114	Async bool
115
116	// RegexpArgs reports which arguments, if any, should be treated as regular
117	// expressions. It takes as input the raw, unexpanded arguments and returns
118	// the list of argument indices that will be interpreted as regular
119	// expressions.
120	//
121	// If RegexpArgs is nil, all arguments are assumed not to be regular
122	// expressions.
123	RegexpArgs func(rawArgs ...string) []int
124}
125
126// A Cond is a condition deciding whether a command should be run.
127type Cond interface {
128	// Eval reports whether the condition applies to the given State.
129	//
130	// If the condition's usage reports that it is a prefix,
131	// the condition must be used with a suffix.
132	// Otherwise, the passed-in suffix argument is always the empty string.
133	Eval(s *State, suffix string) (bool, error)
134
135	// Usage returns the usage for the condition, which the caller must not modify.
136	Usage() *CondUsage
137}
138
139// A CondUsage describes the usage of a Cond, independent of its name
140// (which can change based on its registration).
141type CondUsage struct {
142	Summary string // a single-line summary of when the condition is true
143
144	// If Prefix is true, the condition is a prefix and requires a
145	// colon-separated suffix (like "[GOOS:linux]" for the "GOOS" condition).
146	// The suffix may be the empty string (like "[prefix:]").
147	Prefix bool
148}
149
150// Execute reads and executes script, writing the output to log.
151//
152// Execute stops and returns an error at the first command that does not succeed.
153// The returned error's text begins with "file:line: ".
154//
155// If the script runs to completion or ends by a 'stop' command,
156// Execute returns nil.
157//
158// Execute does not stop background commands started by the script
159// before returning. To stop those, use [State.CloseAndWait] or the
160// [Wait] command.
161func (e *Engine) Execute(s *State, file string, script *bufio.Reader, log io.Writer) (err error) {
162	defer func(prev *Engine) { s.engine = prev }(s.engine)
163	s.engine = e
164
165	var sectionStart time.Time
166	// endSection flushes the logs for the current section from s.log to log.
167	// ok indicates whether all commands in the section succeeded.
168	endSection := func(ok bool) error {
169		var err error
170		if sectionStart.IsZero() {
171			// We didn't write a section header or record a timestamp, so just dump the
172			// whole log without those.
173			if s.log.Len() > 0 {
174				err = s.flushLog(log)
175			}
176		} else if s.log.Len() == 0 {
177			// Adding elapsed time for doing nothing is meaningless, so don't.
178			_, err = io.WriteString(log, "\n")
179		} else {
180			// Insert elapsed time for section at the end of the section's comment.
181			_, err = fmt.Fprintf(log, " (%.3fs)\n", time.Since(sectionStart).Seconds())
182
183			if err == nil && (!ok || !e.Quiet) {
184				err = s.flushLog(log)
185			} else {
186				s.log.Reset()
187			}
188		}
189
190		sectionStart = time.Time{}
191		return err
192	}
193
194	var lineno int
195	lineErr := func(err error) error {
196		if errors.As(err, new(*CommandError)) {
197			return err
198		}
199		return fmt.Errorf("%s:%d: %w", file, lineno, err)
200	}
201
202	// In case of failure or panic, flush any pending logs for the section.
203	defer func() {
204		if sErr := endSection(false); sErr != nil && err == nil {
205			err = lineErr(sErr)
206		}
207	}()
208
209	for {
210		if err := s.ctx.Err(); err != nil {
211			// This error wasn't produced by any particular command,
212			// so don't wrap it in a CommandError.
213			return lineErr(err)
214		}
215
216		line, err := script.ReadString('\n')
217		if err == io.EOF {
218			if line == "" {
219				break // Reached the end of the script.
220			}
221			// If the script doesn't end in a newline, interpret the final line.
222		} else if err != nil {
223			return lineErr(err)
224		}
225		line = strings.TrimSuffix(line, "\n")
226		lineno++
227
228		// The comment character "#" at the start of the line delimits a section of
229		// the script.
230		if strings.HasPrefix(line, "#") {
231			// If there was a previous section, the fact that we are starting a new
232			// one implies the success of the previous one.
233			//
234			// At the start of the script, the state may also contain accumulated logs
235			// from commands executed on the State outside of the engine in order to
236			// set it up; flush those logs too.
237			if err := endSection(true); err != nil {
238				return lineErr(err)
239			}
240
241			// Log the section start without a newline so that we can add
242			// a timestamp for the section when it ends.
243			_, err = fmt.Fprintf(log, "%s", line)
244			sectionStart = time.Now()
245			if err != nil {
246				return lineErr(err)
247			}
248			continue
249		}
250
251		cmd, err := parse(file, lineno, line)
252		if cmd == nil && err == nil {
253			continue // Ignore blank lines.
254		}
255		s.Logf("> %s\n", line)
256		if err != nil {
257			return lineErr(err)
258		}
259
260		// Evaluate condition guards.
261		ok, err := e.conditionsActive(s, cmd.conds)
262		if err != nil {
263			return lineErr(err)
264		}
265		if !ok {
266			s.Logf("[condition not met]\n")
267			continue
268		}
269
270		impl := e.Cmds[cmd.name]
271
272		// Expand variables in arguments.
273		var regexpArgs []int
274		if impl != nil {
275			usage := impl.Usage()
276			if usage.RegexpArgs != nil {
277				// First join rawArgs without expansion to pass to RegexpArgs.
278				rawArgs := make([]string, 0, len(cmd.rawArgs))
279				for _, frags := range cmd.rawArgs {
280					var b strings.Builder
281					for _, frag := range frags {
282						b.WriteString(frag.s)
283					}
284					rawArgs = append(rawArgs, b.String())
285				}
286				regexpArgs = usage.RegexpArgs(rawArgs...)
287			}
288		}
289		cmd.args = expandArgs(s, cmd.rawArgs, regexpArgs)
290
291		// Run the command.
292		err = e.runCommand(s, cmd, impl)
293		if err != nil {
294			if stop := (stopError{}); errors.As(err, &stop) {
295				// Since the 'stop' command halts execution of the entire script,
296				// log its message separately from the section in which it appears.
297				err = endSection(true)
298				s.Logf("%v\n", stop)
299				if err == nil {
300					return nil
301				}
302			}
303			return lineErr(err)
304		}
305	}
306
307	if err := endSection(true); err != nil {
308		return lineErr(err)
309	}
310	return nil
311}
312
313// A command is a complete command parsed from a script.
314type command struct {
315	file       string
316	line       int
317	want       expectedStatus
318	conds      []condition // all must be satisfied
319	name       string      // the name of the command; must be non-empty
320	rawArgs    [][]argFragment
321	args       []string // shell-expanded arguments following name
322	background bool     // command should run in background (ends with a trailing &)
323}
324
325// An expectedStatus describes the expected outcome of a command.
326// Script execution halts when a command does not match its expected status.
327type expectedStatus string
328
329const (
330	success          expectedStatus = ""
331	failure          expectedStatus = "!"
332	successOrFailure expectedStatus = "?"
333)
334
335type argFragment struct {
336	s      string
337	quoted bool // if true, disable variable expansion for this fragment
338}
339
340type condition struct {
341	want bool
342	tag  string
343}
344
345const argSepChars = " \t\r\n#"
346
347// parse parses a single line as a list of space-separated arguments.
348// subject to environment variable expansion (but not resplitting).
349// Single quotes around text disable splitting and expansion.
350// To embed a single quote, double it:
351//
352//	'Don''t communicate by sharing memory.'
353func parse(filename string, lineno int, line string) (cmd *command, err error) {
354	cmd = &command{file: filename, line: lineno}
355	var (
356		rawArg []argFragment // text fragments of current arg so far (need to add line[start:i])
357		start  = -1          // if >= 0, position where current arg text chunk starts
358		quoted = false       // currently processing quoted text
359	)
360
361	flushArg := func() error {
362		if len(rawArg) == 0 {
363			return nil // Nothing to flush.
364		}
365		defer func() { rawArg = nil }()
366
367		if cmd.name == "" && len(rawArg) == 1 && !rawArg[0].quoted {
368			arg := rawArg[0].s
369
370			// Command prefix ! means negate the expectations about this command:
371			// go command should fail, match should not be found, etc.
372			// Prefix ? means allow either success or failure.
373			switch want := expectedStatus(arg); want {
374			case failure, successOrFailure:
375				if cmd.want != "" {
376					return errors.New("duplicated '!' or '?' token")
377				}
378				cmd.want = want
379				return nil
380			}
381
382			// Command prefix [cond] means only run this command if cond is satisfied.
383			if strings.HasPrefix(arg, "[") && strings.HasSuffix(arg, "]") {
384				want := true
385				arg = strings.TrimSpace(arg[1 : len(arg)-1])
386				if strings.HasPrefix(arg, "!") {
387					want = false
388					arg = strings.TrimSpace(arg[1:])
389				}
390				if arg == "" {
391					return errors.New("empty condition")
392				}
393				cmd.conds = append(cmd.conds, condition{want: want, tag: arg})
394				return nil
395			}
396
397			if arg == "" {
398				return errors.New("empty command")
399			}
400			cmd.name = arg
401			return nil
402		}
403
404		cmd.rawArgs = append(cmd.rawArgs, rawArg)
405		return nil
406	}
407
408	for i := 0; ; i++ {
409		if !quoted && (i >= len(line) || strings.ContainsRune(argSepChars, rune(line[i]))) {
410			// Found arg-separating space.
411			if start >= 0 {
412				rawArg = append(rawArg, argFragment{s: line[start:i], quoted: false})
413				start = -1
414			}
415			if err := flushArg(); err != nil {
416				return nil, err
417			}
418			if i >= len(line) || line[i] == '#' {
419				break
420			}
421			continue
422		}
423		if i >= len(line) {
424			return nil, errors.New("unterminated quoted argument")
425		}
426		if line[i] == '\'' {
427			if !quoted {
428				// starting a quoted chunk
429				if start >= 0 {
430					rawArg = append(rawArg, argFragment{s: line[start:i], quoted: false})
431				}
432				start = i + 1
433				quoted = true
434				continue
435			}
436			// 'foo''bar' means foo'bar, like in rc shell and Pascal.
437			if i+1 < len(line) && line[i+1] == '\'' {
438				rawArg = append(rawArg, argFragment{s: line[start:i], quoted: true})
439				start = i + 1
440				i++ // skip over second ' before next iteration
441				continue
442			}
443			// ending a quoted chunk
444			rawArg = append(rawArg, argFragment{s: line[start:i], quoted: true})
445			start = i + 1
446			quoted = false
447			continue
448		}
449		// found character worth saving; make sure we're saving
450		if start < 0 {
451			start = i
452		}
453	}
454
455	if cmd.name == "" {
456		if cmd.want != "" || len(cmd.conds) > 0 || len(cmd.rawArgs) > 0 || cmd.background {
457			// The line contains a command prefix or suffix, but no actual command.
458			return nil, errors.New("missing command")
459		}
460
461		// The line is blank, or contains only a comment.
462		return nil, nil
463	}
464
465	if n := len(cmd.rawArgs); n > 0 {
466		last := cmd.rawArgs[n-1]
467		if len(last) == 1 && !last[0].quoted && last[0].s == "&" {
468			cmd.background = true
469			cmd.rawArgs = cmd.rawArgs[:n-1]
470		}
471	}
472	return cmd, nil
473}
474
475// expandArgs expands the shell variables in rawArgs and joins them to form the
476// final arguments to pass to a command.
477func expandArgs(s *State, rawArgs [][]argFragment, regexpArgs []int) []string {
478	args := make([]string, 0, len(rawArgs))
479	for i, frags := range rawArgs {
480		isRegexp := false
481		for _, j := range regexpArgs {
482			if i == j {
483				isRegexp = true
484				break
485			}
486		}
487
488		var b strings.Builder
489		for _, frag := range frags {
490			if frag.quoted {
491				b.WriteString(frag.s)
492			} else {
493				b.WriteString(s.ExpandEnv(frag.s, isRegexp))
494			}
495		}
496		args = append(args, b.String())
497	}
498	return args
499}
500
501// quoteArgs returns a string that parse would parse as args when passed to a command.
502//
503// TODO(bcmills): This function should have a fuzz test.
504func quoteArgs(args []string) string {
505	var b strings.Builder
506	for i, arg := range args {
507		if i > 0 {
508			b.WriteString(" ")
509		}
510		if strings.ContainsAny(arg, "'"+argSepChars) {
511			// Quote the argument to a form that would be parsed as a single argument.
512			b.WriteString("'")
513			b.WriteString(strings.ReplaceAll(arg, "'", "''"))
514			b.WriteString("'")
515		} else {
516			b.WriteString(arg)
517		}
518	}
519	return b.String()
520}
521
522func (e *Engine) conditionsActive(s *State, conds []condition) (bool, error) {
523	for _, cond := range conds {
524		var impl Cond
525		prefix, suffix, ok := strings.Cut(cond.tag, ":")
526		if ok {
527			impl = e.Conds[prefix]
528			if impl == nil {
529				return false, fmt.Errorf("unknown condition prefix %q", prefix)
530			}
531			if !impl.Usage().Prefix {
532				return false, fmt.Errorf("condition %q cannot be used with a suffix", prefix)
533			}
534		} else {
535			impl = e.Conds[cond.tag]
536			if impl == nil {
537				return false, fmt.Errorf("unknown condition %q", cond.tag)
538			}
539			if impl.Usage().Prefix {
540				return false, fmt.Errorf("condition %q requires a suffix", cond.tag)
541			}
542		}
543		active, err := impl.Eval(s, suffix)
544
545		if err != nil {
546			return false, fmt.Errorf("evaluating condition %q: %w", cond.tag, err)
547		}
548		if active != cond.want {
549			return false, nil
550		}
551	}
552
553	return true, nil
554}
555
556func (e *Engine) runCommand(s *State, cmd *command, impl Cmd) error {
557	if impl == nil {
558		return cmdError(cmd, errors.New("unknown command"))
559	}
560
561	async := impl.Usage().Async
562	if cmd.background && !async {
563		return cmdError(cmd, errors.New("command cannot be run in background"))
564	}
565
566	wait, runErr := impl.Run(s, cmd.args...)
567	if wait == nil {
568		if async && runErr == nil {
569			return cmdError(cmd, errors.New("internal error: async command returned a nil WaitFunc"))
570		}
571		return checkStatus(cmd, runErr)
572	}
573	if runErr != nil {
574		return cmdError(cmd, errors.New("internal error: command returned both an error and a WaitFunc"))
575	}
576
577	if cmd.background {
578		s.background = append(s.background, backgroundCmd{
579			command: cmd,
580			wait:    wait,
581		})
582		// Clear stdout and stderr, since they no longer correspond to the last
583		// command executed.
584		s.stdout = ""
585		s.stderr = ""
586		return nil
587	}
588
589	if wait != nil {
590		stdout, stderr, waitErr := wait(s)
591		s.stdout = stdout
592		s.stderr = stderr
593		if stdout != "" {
594			s.Logf("[stdout]\n%s", stdout)
595		}
596		if stderr != "" {
597			s.Logf("[stderr]\n%s", stderr)
598		}
599		if cmdErr := checkStatus(cmd, waitErr); cmdErr != nil {
600			return cmdErr
601		}
602		if waitErr != nil {
603			// waitErr was expected (by cmd.want), so log it instead of returning it.
604			s.Logf("[%v]\n", waitErr)
605		}
606	}
607	return nil
608}
609
610func checkStatus(cmd *command, err error) error {
611	if err == nil {
612		if cmd.want == failure {
613			return cmdError(cmd, ErrUnexpectedSuccess)
614		}
615		return nil
616	}
617
618	if s := (stopError{}); errors.As(err, &s) {
619		// This error originated in the Stop command.
620		// Propagate it as-is.
621		return cmdError(cmd, err)
622	}
623
624	if w := (waitError{}); errors.As(err, &w) {
625		// This error was surfaced from a background process by a call to Wait.
626		// Add a call frame for Wait itself, but ignore its "want" field.
627		// (Wait itself cannot fail to wait on commands or else it would leak
628		// processes and/or goroutines — so a negative assertion for it would be at
629		// best ambiguous.)
630		return cmdError(cmd, err)
631	}
632
633	if cmd.want == success {
634		return cmdError(cmd, err)
635	}
636
637	if cmd.want == failure && (errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) {
638		// The command was terminated because the script is no longer interested in
639		// its output, so we don't know what it would have done had it run to
640		// completion — for all we know, it could have exited without error if it
641		// ran just a smidge faster.
642		return cmdError(cmd, err)
643	}
644
645	return nil
646}
647
648// ListCmds prints to w a list of the named commands,
649// annotating each with its arguments and a short usage summary.
650// If verbose is true, ListCmds prints full details for each command.
651//
652// Each of the name arguments should be a command name.
653// If no names are passed as arguments, ListCmds lists all the
654// commands registered in e.
655func (e *Engine) ListCmds(w io.Writer, verbose bool, names ...string) error {
656	if names == nil {
657		names = make([]string, 0, len(e.Cmds))
658		for name := range e.Cmds {
659			names = append(names, name)
660		}
661		sort.Strings(names)
662	}
663
664	for _, name := range names {
665		cmd := e.Cmds[name]
666		usage := cmd.Usage()
667
668		suffix := ""
669		if usage.Async {
670			suffix = " [&]"
671		}
672
673		_, err := fmt.Fprintf(w, "%s %s%s\n\t%s\n", name, usage.Args, suffix, usage.Summary)
674		if err != nil {
675			return err
676		}
677
678		if verbose {
679			if _, err := io.WriteString(w, "\n"); err != nil {
680				return err
681			}
682			for _, line := range usage.Detail {
683				if err := wrapLine(w, line, 60, "\t"); err != nil {
684					return err
685				}
686			}
687			if _, err := io.WriteString(w, "\n"); err != nil {
688				return err
689			}
690		}
691	}
692
693	return nil
694}
695
696func wrapLine(w io.Writer, line string, cols int, indent string) error {
697	line = strings.TrimLeft(line, " ")
698	for len(line) > cols {
699		bestSpace := -1
700		for i, r := range line {
701			if r == ' ' {
702				if i <= cols || bestSpace < 0 {
703					bestSpace = i
704				}
705				if i > cols {
706					break
707				}
708			}
709		}
710		if bestSpace < 0 {
711			break
712		}
713
714		if _, err := fmt.Fprintf(w, "%s%s\n", indent, line[:bestSpace]); err != nil {
715			return err
716		}
717		line = line[bestSpace+1:]
718	}
719
720	_, err := fmt.Fprintf(w, "%s%s\n", indent, line)
721	return err
722}
723
724// ListConds prints to w a list of conditions, one per line,
725// annotating each with a description and whether the condition
726// is true in the state s (if s is non-nil).
727//
728// Each of the tag arguments should be a condition string of
729// the form "name" or "name:suffix". If no tags are passed as
730// arguments, ListConds lists all conditions registered in
731// the engine e.
732func (e *Engine) ListConds(w io.Writer, s *State, tags ...string) error {
733	if tags == nil {
734		tags = make([]string, 0, len(e.Conds))
735		for name := range e.Conds {
736			tags = append(tags, name)
737		}
738		sort.Strings(tags)
739	}
740
741	for _, tag := range tags {
742		if prefix, suffix, ok := strings.Cut(tag, ":"); ok {
743			cond := e.Conds[prefix]
744			if cond == nil {
745				return fmt.Errorf("unknown condition prefix %q", prefix)
746			}
747			usage := cond.Usage()
748			if !usage.Prefix {
749				return fmt.Errorf("condition %q cannot be used with a suffix", prefix)
750			}
751
752			activeStr := ""
753			if s != nil {
754				if active, _ := cond.Eval(s, suffix); active {
755					activeStr = " (active)"
756				}
757			}
758			_, err := fmt.Fprintf(w, "[%s]%s\n\t%s\n", tag, activeStr, usage.Summary)
759			if err != nil {
760				return err
761			}
762			continue
763		}
764
765		cond := e.Conds[tag]
766		if cond == nil {
767			return fmt.Errorf("unknown condition %q", tag)
768		}
769		var err error
770		usage := cond.Usage()
771		if usage.Prefix {
772			_, err = fmt.Fprintf(w, "[%s:*]\n\t%s\n", tag, usage.Summary)
773		} else {
774			activeStr := ""
775			if s != nil {
776				if ok, _ := cond.Eval(s, ""); ok {
777					activeStr = " (active)"
778				}
779			}
780			_, err = fmt.Fprintf(w, "[%s]%s\n\t%s\n", tag, activeStr, usage.Summary)
781		}
782		if err != nil {
783			return err
784		}
785	}
786
787	return nil
788}
789