diff --git a/parse.go b/parse.go index 623211b..639b89b 100644 --- a/parse.go +++ b/parse.go @@ -12,6 +12,8 @@ func stripComment(line string) string { for i := 0; i < len(line); i++ { ch := line[i] switch { + case ch == '\\' && !inSingle: + i++ // skip escaped character (backslash escapes in double-quoted and unquoted contexts) case ch == '\'' && !inDouble: inSingle = !inSingle case ch == '"' && !inSingle: diff --git a/run.go b/run.go index 887c61c..34307a8 100644 --- a/run.go +++ b/run.go @@ -1,6 +1,8 @@ package main import ( + "crypto/rand" + "fmt" "io" "os" "os/exec" @@ -11,7 +13,11 @@ import ( "time" ) -const sentinelPrefix = "__SHOUT_SENTINEL_" +func newSentinelPrefix() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return fmt.Sprintf("__SHOUT_%x_", b) +} type RunOptions struct { CleanEnv bool @@ -22,7 +28,7 @@ type RunOptions struct { OnCommand func(Command) } -func buildScript(commands []Command) string { +func buildScript(commands []Command, sentinelPrefix string) string { var b strings.Builder b.WriteString("exec 2>&1\n") for i, cmd := range commands { @@ -33,7 +39,7 @@ func buildScript(commands []Command) string { return b.String() } -func parseSentinelOutput(raw string, commandCount int) (outputs [][]string, exitCodes []int) { +func parseSentinelOutput(raw string, commandCount int, sentinelPrefix string) (outputs [][]string, exitCodes []int) { re := regexp.MustCompile(regexp.QuoteMeta(sentinelPrefix) + `(\d+)_(\d+)__`) remaining := raw @@ -91,7 +97,8 @@ func runFile(file ShoutFile, opts RunOptions) FileResult { return FileResult{File: file, TmpDir: tmpDir} } - script := buildScript(file.Commands) + sentinel := newSentinelPrefix() + script := buildScript(file.Commands, sentinel) // Build environment var envMap map[string]string @@ -115,7 +122,12 @@ func runFile(file ShoutFile, opts RunOptions) FileResult { if len(opts.PathDirs) > 0 { existing := envMap["PATH"] - envMap["PATH"] = strings.Join(opts.PathDirs, ":") + ":" + existing + prepend := strings.Join(opts.PathDirs, ":") + if existing != "" { + envMap["PATH"] = prepend + ":" + existing + } else { + envMap["PATH"] = prepend + } } envSlice := make([]string, 0, len(envMap)) @@ -172,12 +184,17 @@ func runFile(file ShoutFile, opts RunOptions) FileResult { } output = r.data case <-time.After(totalTimeout): + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + <-ch // drain the reader goroutine so pipe FDs are released + _ = cmd.Wait() return FileResult{File: file, TmpDir: tmpDir, Error: "Timeout reading output"} } _ = cmd.Wait() - outputs, exitCodesList := parseSentinelOutput(string(output), len(file.Commands)) + outputs, exitCodesList := parseSentinelOutput(string(output), len(file.Commands), sentinel) results := make([]CommandResult, len(file.Commands)) for i, c := range file.Commands { diff --git a/shout b/shout index 440a587..7f5643b 100755 Binary files a/shout and b/shout differ diff --git a/update.go b/update.go index aa812b0..37fe93b 100644 --- a/update.go +++ b/update.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "regexp" "strings" ) @@ -20,6 +21,13 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string output = append(output, line) if cmdIdx >= len(file.Commands) || cmdIdx >= len(results) { + // Out of bounds — skip past expected output, preserving original lines + j := i + 1 + for j < len(lines) && !strings.HasPrefix(lines[j], "$ ") { + j++ + } + output = append(output, lines[i+1:j]...) + i = j - 1 cmdIdx++ continue } @@ -62,6 +70,10 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string output = append(output, result.Actual...) if oldExitMarker != "" { output = append(output, oldExitMarker) + } else if len(result.Actual) > 0 && exitCodeMarkerRe.MatchString(result.Actual[len(result.Actual)-1]) { + // Actual output's last line looks like an exit code marker. + // Add an explicit marker to prevent parser from consuming it as one. + output = append(output, fmt.Sprintf("[%d]", result.ExitCode)) } for k := 0; k < trailingBlanks; k++ { output = append(output, "")