| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | package script |
| |
|
| | import ( |
| | "bufio" |
| | "context" |
| | "errors" |
| | "fmt" |
| | "io" |
| | "maps" |
| | "slices" |
| | "sort" |
| | "strings" |
| | "time" |
| | ) |
| |
|
| | |
| | |
| | |
| | type Engine struct { |
| | Cmds map[string]Cmd |
| | Conds map[string]Cond |
| |
|
| | |
| | |
| | Quiet bool |
| | } |
| |
|
| | |
| | type Cmd interface { |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | Run(s *State, args ...string) (WaitFunc, error) |
| |
|
| | |
| | Usage() *CmdUsage |
| | } |
| |
|
| | |
| | type WaitFunc func(*State) (stdout, stderr string, err error) |
| |
|
| | |
| | |
| | type CmdUsage struct { |
| | Summary string |
| | Args string |
| | Detail []string |
| |
|
| | |
| | |
| | Async bool |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | RegexpArgs func(rawArgs ...string) []int |
| | } |
| |
|
| | |
| | type Cond interface { |
| | |
| | |
| | |
| | |
| | |
| | Eval(s *State, suffix string) (bool, error) |
| |
|
| | |
| | Usage() *CondUsage |
| | } |
| |
|
| | |
| | |
| | type CondUsage struct { |
| | Summary string |
| |
|
| | |
| | |
| | |
| | Prefix bool |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | func (e *Engine) Execute(s *State, file string, script *bufio.Reader, log io.Writer) (err error) { |
| | defer func(prev *Engine) { s.engine = prev }(s.engine) |
| | s.engine = e |
| |
|
| | var sectionStart time.Time |
| | |
| | |
| | endSection := func(ok bool) error { |
| | var err error |
| | if sectionStart.IsZero() { |
| | |
| | |
| | if s.log.Len() > 0 { |
| | err = s.flushLog(log) |
| | } |
| | } else if s.log.Len() == 0 { |
| | |
| | _, err = io.WriteString(log, "\n") |
| | } else { |
| | |
| | _, err = fmt.Fprintf(log, " (%.3fs)\n", time.Since(sectionStart).Seconds()) |
| |
|
| | if err == nil && (!ok || !e.Quiet) { |
| | err = s.flushLog(log) |
| | } else { |
| | s.log.Reset() |
| | } |
| | } |
| |
|
| | sectionStart = time.Time{} |
| | return err |
| | } |
| |
|
| | var lineno int |
| | lineErr := func(err error) error { |
| | if _, ok := errors.AsType[*CommandError](err); ok { |
| | return err |
| | } |
| | return fmt.Errorf("%s:%d: %w", file, lineno, err) |
| | } |
| |
|
| | |
| | defer func() { |
| | if sErr := endSection(false); sErr != nil && err == nil { |
| | err = lineErr(sErr) |
| | } |
| | }() |
| |
|
| | for { |
| | if err := s.ctx.Err(); err != nil { |
| | |
| | |
| | return lineErr(err) |
| | } |
| |
|
| | line, err := script.ReadString('\n') |
| | if err == io.EOF { |
| | if line == "" { |
| | break |
| | } |
| | |
| | } else if err != nil { |
| | return lineErr(err) |
| | } |
| | line = strings.TrimSuffix(line, "\n") |
| | lineno++ |
| |
|
| | |
| | |
| | if strings.HasPrefix(line, "#") { |
| | |
| | |
| | |
| | |
| | |
| | |
| | if err := endSection(true); err != nil { |
| | return lineErr(err) |
| | } |
| |
|
| | |
| | |
| | _, err = fmt.Fprintf(log, "%s", line) |
| | sectionStart = time.Now() |
| | if err != nil { |
| | return lineErr(err) |
| | } |
| | continue |
| | } |
| |
|
| | cmd, err := parse(file, lineno, line) |
| | if cmd == nil && err == nil { |
| | continue |
| | } |
| | s.Logf("> %s\n", line) |
| | if err != nil { |
| | return lineErr(err) |
| | } |
| |
|
| | |
| | ok, err := e.conditionsActive(s, cmd.conds) |
| | if err != nil { |
| | return lineErr(err) |
| | } |
| | if !ok { |
| | s.Logf("[condition not met]\n") |
| | continue |
| | } |
| |
|
| | impl := e.Cmds[cmd.name] |
| |
|
| | |
| | var regexpArgs []int |
| | if impl != nil { |
| | usage := impl.Usage() |
| | if usage.RegexpArgs != nil { |
| | |
| | rawArgs := make([]string, 0, len(cmd.rawArgs)) |
| | for _, frags := range cmd.rawArgs { |
| | var b strings.Builder |
| | for _, frag := range frags { |
| | b.WriteString(frag.s) |
| | } |
| | rawArgs = append(rawArgs, b.String()) |
| | } |
| | regexpArgs = usage.RegexpArgs(rawArgs...) |
| | } |
| | } |
| | cmd.args = expandArgs(s, cmd.rawArgs, regexpArgs) |
| |
|
| | |
| | err = e.runCommand(s, cmd, impl) |
| | if err != nil { |
| | if stop, ok := errors.AsType[stopError](err); ok { |
| | |
| | |
| | err = endSection(true) |
| | s.Logf("%v\n", stop) |
| | if err == nil { |
| | return nil |
| | } |
| | } |
| | return lineErr(err) |
| | } |
| | } |
| |
|
| | if err := endSection(true); err != nil { |
| | return lineErr(err) |
| | } |
| | return nil |
| | } |
| |
|
| | |
| | type command struct { |
| | file string |
| | line int |
| | want expectedStatus |
| | conds []condition |
| | name string |
| | rawArgs [][]argFragment |
| | args []string |
| | background bool |
| | } |
| |
|
| | |
| | |
| | type expectedStatus string |
| |
|
| | const ( |
| | success expectedStatus = "" |
| | failure expectedStatus = "!" |
| | successOrFailure expectedStatus = "?" |
| | ) |
| |
|
| | type argFragment struct { |
| | s string |
| | quoted bool |
| | } |
| |
|
| | type condition struct { |
| | want bool |
| | tag string |
| | } |
| |
|
| | const argSepChars = " \t\r\n#" |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | func parse(filename string, lineno int, line string) (cmd *command, err error) { |
| | cmd = &command{file: filename, line: lineno} |
| | var ( |
| | rawArg []argFragment |
| | start = -1 |
| | quoted = false |
| | ) |
| |
|
| | flushArg := func() error { |
| | if len(rawArg) == 0 { |
| | return nil |
| | } |
| | defer func() { rawArg = nil }() |
| |
|
| | if cmd.name == "" && len(rawArg) == 1 && !rawArg[0].quoted { |
| | arg := rawArg[0].s |
| |
|
| | |
| | |
| | |
| | switch want := expectedStatus(arg); want { |
| | case failure, successOrFailure: |
| | if cmd.want != "" { |
| | return errors.New("duplicated '!' or '?' token") |
| | } |
| | cmd.want = want |
| | return nil |
| | } |
| |
|
| | |
| | if strings.HasPrefix(arg, "[") && strings.HasSuffix(arg, "]") { |
| | want := true |
| | arg = strings.TrimSpace(arg[1 : len(arg)-1]) |
| | if strings.HasPrefix(arg, "!") { |
| | want = false |
| | arg = strings.TrimSpace(arg[1:]) |
| | } |
| | if arg == "" { |
| | return errors.New("empty condition") |
| | } |
| | cmd.conds = append(cmd.conds, condition{want: want, tag: arg}) |
| | return nil |
| | } |
| |
|
| | if arg == "" { |
| | return errors.New("empty command") |
| | } |
| | cmd.name = arg |
| | return nil |
| | } |
| |
|
| | cmd.rawArgs = append(cmd.rawArgs, rawArg) |
| | return nil |
| | } |
| |
|
| | for i := 0; ; i++ { |
| | if !quoted && (i >= len(line) || strings.ContainsRune(argSepChars, rune(line[i]))) { |
| | |
| | if start >= 0 { |
| | rawArg = append(rawArg, argFragment{s: line[start:i], quoted: false}) |
| | start = -1 |
| | } |
| | if err := flushArg(); err != nil { |
| | return nil, err |
| | } |
| | if i >= len(line) || line[i] == '#' { |
| | break |
| | } |
| | continue |
| | } |
| | if i >= len(line) { |
| | return nil, errors.New("unterminated quoted argument") |
| | } |
| | if line[i] == '\'' { |
| | if !quoted { |
| | |
| | if start >= 0 { |
| | rawArg = append(rawArg, argFragment{s: line[start:i], quoted: false}) |
| | } |
| | start = i + 1 |
| | quoted = true |
| | continue |
| | } |
| | |
| | if i+1 < len(line) && line[i+1] == '\'' { |
| | rawArg = append(rawArg, argFragment{s: line[start:i], quoted: true}) |
| | start = i + 1 |
| | i++ |
| | continue |
| | } |
| | |
| | rawArg = append(rawArg, argFragment{s: line[start:i], quoted: true}) |
| | start = i + 1 |
| | quoted = false |
| | continue |
| | } |
| | |
| | if start < 0 { |
| | start = i |
| | } |
| | } |
| |
|
| | if cmd.name == "" { |
| | if cmd.want != "" || len(cmd.conds) > 0 || len(cmd.rawArgs) > 0 || cmd.background { |
| | |
| | return nil, errors.New("missing command") |
| | } |
| |
|
| | |
| | return nil, nil |
| | } |
| |
|
| | if n := len(cmd.rawArgs); n > 0 { |
| | last := cmd.rawArgs[n-1] |
| | if len(last) == 1 && !last[0].quoted && last[0].s == "&" { |
| | cmd.background = true |
| | cmd.rawArgs = cmd.rawArgs[:n-1] |
| | } |
| | } |
| | return cmd, nil |
| | } |
| |
|
| | |
| | |
| | func expandArgs(s *State, rawArgs [][]argFragment, regexpArgs []int) []string { |
| | args := make([]string, 0, len(rawArgs)) |
| | for i, frags := range rawArgs { |
| | isRegexp := false |
| | for _, j := range regexpArgs { |
| | if i == j { |
| | isRegexp = true |
| | break |
| | } |
| | } |
| |
|
| | var b strings.Builder |
| | for _, frag := range frags { |
| | if frag.quoted { |
| | b.WriteString(frag.s) |
| | } else { |
| | b.WriteString(s.ExpandEnv(frag.s, isRegexp)) |
| | } |
| | } |
| | args = append(args, b.String()) |
| | } |
| | return args |
| | } |
| |
|
| | |
| | |
| | |
| | func quoteArgs(args []string) string { |
| | var b strings.Builder |
| | for i, arg := range args { |
| | if i > 0 { |
| | b.WriteString(" ") |
| | } |
| | if strings.ContainsAny(arg, "'"+argSepChars) { |
| | |
| | b.WriteString("'") |
| | b.WriteString(strings.ReplaceAll(arg, "'", "''")) |
| | b.WriteString("'") |
| | } else { |
| | b.WriteString(arg) |
| | } |
| | } |
| | return b.String() |
| | } |
| |
|
| | func (e *Engine) conditionsActive(s *State, conds []condition) (bool, error) { |
| | for _, cond := range conds { |
| | var impl Cond |
| | prefix, suffix, ok := strings.Cut(cond.tag, ":") |
| | if ok { |
| | impl = e.Conds[prefix] |
| | if impl == nil { |
| | return false, fmt.Errorf("unknown condition prefix %q; known: %v", prefix, slices.Collect(maps.Keys(e.Conds))) |
| | } |
| | if !impl.Usage().Prefix { |
| | return false, fmt.Errorf("condition %q cannot be used with a suffix", prefix) |
| | } |
| | } else { |
| | impl = e.Conds[cond.tag] |
| | if impl == nil { |
| | return false, fmt.Errorf("unknown condition %q", cond.tag) |
| | } |
| | if impl.Usage().Prefix { |
| | return false, fmt.Errorf("condition %q requires a suffix", cond.tag) |
| | } |
| | } |
| | active, err := impl.Eval(s, suffix) |
| |
|
| | if err != nil { |
| | return false, fmt.Errorf("evaluating condition %q: %w", cond.tag, err) |
| | } |
| | if active != cond.want { |
| | return false, nil |
| | } |
| | } |
| |
|
| | return true, nil |
| | } |
| |
|
| | func (e *Engine) runCommand(s *State, cmd *command, impl Cmd) error { |
| | if impl == nil { |
| | return cmdError(cmd, errors.New("unknown command")) |
| | } |
| |
|
| | async := impl.Usage().Async |
| | if cmd.background && !async { |
| | return cmdError(cmd, errors.New("command cannot be run in background")) |
| | } |
| |
|
| | wait, runErr := impl.Run(s, cmd.args...) |
| | if wait == nil { |
| | if async && runErr == nil { |
| | return cmdError(cmd, errors.New("internal error: async command returned a nil WaitFunc")) |
| | } |
| | return checkStatus(cmd, runErr) |
| | } |
| | if runErr != nil { |
| | return cmdError(cmd, errors.New("internal error: command returned both an error and a WaitFunc")) |
| | } |
| |
|
| | if cmd.background { |
| | s.background = append(s.background, backgroundCmd{ |
| | command: cmd, |
| | wait: wait, |
| | }) |
| | |
| | |
| | s.stdout = "" |
| | s.stderr = "" |
| | return nil |
| | } |
| |
|
| | if wait != nil { |
| | stdout, stderr, waitErr := wait(s) |
| | s.stdout = stdout |
| | s.stderr = stderr |
| | if stdout != "" { |
| | s.Logf("[stdout]\n%s", stdout) |
| | } |
| | if stderr != "" { |
| | s.Logf("[stderr]\n%s", stderr) |
| | } |
| | if cmdErr := checkStatus(cmd, waitErr); cmdErr != nil { |
| | return cmdErr |
| | } |
| | if waitErr != nil { |
| | |
| | s.Logf("[%v]\n", waitErr) |
| | } |
| | } |
| | return nil |
| | } |
| |
|
| | func checkStatus(cmd *command, err error) error { |
| | if err == nil { |
| | if cmd.want == failure { |
| | return cmdError(cmd, ErrUnexpectedSuccess) |
| | } |
| | return nil |
| | } |
| |
|
| | if _, ok := errors.AsType[stopError](err); ok { |
| | |
| | |
| | return cmdError(cmd, err) |
| | } |
| |
|
| | if _, ok := errors.AsType[waitError](err); ok { |
| | |
| | |
| | |
| | |
| | |
| | return cmdError(cmd, err) |
| | } |
| |
|
| | if cmd.want == success { |
| | return cmdError(cmd, err) |
| | } |
| |
|
| | if cmd.want == failure && (errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) { |
| | |
| | |
| | |
| | |
| | return cmdError(cmd, err) |
| | } |
| |
|
| | return nil |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | func (e *Engine) ListCmds(w io.Writer, verbose bool, names ...string) error { |
| | if names == nil { |
| | names = make([]string, 0, len(e.Cmds)) |
| | for name := range e.Cmds { |
| | names = append(names, name) |
| | } |
| | sort.Strings(names) |
| | } |
| |
|
| | for _, name := range names { |
| | cmd := e.Cmds[name] |
| | usage := cmd.Usage() |
| |
|
| | suffix := "" |
| | if usage.Async { |
| | suffix = " [&]" |
| | } |
| |
|
| | _, err := fmt.Fprintf(w, "%s %s%s\n\t%s\n", name, usage.Args, suffix, usage.Summary) |
| | if err != nil { |
| | return err |
| | } |
| |
|
| | if verbose { |
| | if _, err := io.WriteString(w, "\n"); err != nil { |
| | return err |
| | } |
| | for _, line := range usage.Detail { |
| | if err := wrapLine(w, line, 60, "\t"); err != nil { |
| | return err |
| | } |
| | } |
| | if _, err := io.WriteString(w, "\n"); err != nil { |
| | return err |
| | } |
| | } |
| | } |
| |
|
| | return nil |
| | } |
| |
|
| | func wrapLine(w io.Writer, line string, cols int, indent string) error { |
| | line = strings.TrimLeft(line, " ") |
| | for len(line) > cols { |
| | bestSpace := -1 |
| | for i, r := range line { |
| | if r == ' ' { |
| | if i <= cols || bestSpace < 0 { |
| | bestSpace = i |
| | } |
| | if i > cols { |
| | break |
| | } |
| | } |
| | } |
| | if bestSpace < 0 { |
| | break |
| | } |
| |
|
| | if _, err := fmt.Fprintf(w, "%s%s\n", indent, line[:bestSpace]); err != nil { |
| | return err |
| | } |
| | line = line[bestSpace+1:] |
| | } |
| |
|
| | _, err := fmt.Fprintf(w, "%s%s\n", indent, line) |
| | return err |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | func (e *Engine) ListConds(w io.Writer, s *State, tags ...string) error { |
| | if tags == nil { |
| | tags = make([]string, 0, len(e.Conds)) |
| | for name := range e.Conds { |
| | tags = append(tags, name) |
| | } |
| | sort.Strings(tags) |
| | } |
| |
|
| | for _, tag := range tags { |
| | if prefix, suffix, ok := strings.Cut(tag, ":"); ok { |
| | cond := e.Conds[prefix] |
| | if cond == nil { |
| | return fmt.Errorf("unknown condition prefix %q", prefix) |
| | } |
| | usage := cond.Usage() |
| | if !usage.Prefix { |
| | return fmt.Errorf("condition %q cannot be used with a suffix", prefix) |
| | } |
| |
|
| | activeStr := "" |
| | if s != nil { |
| | if active, _ := cond.Eval(s, suffix); active { |
| | activeStr = " (active)" |
| | } |
| | } |
| | _, err := fmt.Fprintf(w, "[%s]%s\n\t%s\n", tag, activeStr, usage.Summary) |
| | if err != nil { |
| | return err |
| | } |
| | continue |
| | } |
| |
|
| | cond := e.Conds[tag] |
| | if cond == nil { |
| | return fmt.Errorf("unknown condition %q", tag) |
| | } |
| | var err error |
| | usage := cond.Usage() |
| | if usage.Prefix { |
| | _, err = fmt.Fprintf(w, "[%s:*]\n\t%s\n", tag, usage.Summary) |
| | } else { |
| | activeStr := "" |
| | if s != nil { |
| | if ok, _ := cond.Eval(s, ""); ok { |
| | activeStr = " (active)" |
| | } |
| | } |
| | _, err = fmt.Fprintf(w, "[%s]%s\n\t%s\n", tag, activeStr, usage.Summary) |
| | } |
| | if err != nil { |
| | return err |
| | } |
| | } |
| |
|
| | return nil |
| | } |
| |
|