Repository: mvdan/sh
Pull request #1113 (2024-12-02 21:15:34)

pattern: fix outdated comment, and add godoc links
TranslatePattern and QuotePattern were renamed Regexp and QuoteMeta
in 2019 when code was moved out "syntax" package to "pattern" one.

See 2e72e3c7a085f23431db3c6cfb01da390fd5003c



Patch:
diff --git a/cmd/shfmt/main.go b/cmd/shfmt/main.go
index 37e40898..8c3d5053 100644
--- a/cmd/shfmt/main.go
+++ b/cmd/shfmt/main.go
@@ -53,9 +53,10 @@ var (
 	diff        = &multiFlag[bool]{"d", "diff", false}
 	applyIgnore = &multiFlag[bool]{"", "apply-ignore", false}
 
-	lang     = &multiFlag[syntax.LangVariant]{"ln", "language-dialect", syntax.LangAuto}
-	posix    = &multiFlag[bool]{"p", "posix", false}
-	filename = &multiFlag[string]{"", "filename", ""}
+	lang       = &multiFlag[syntax.LangVariant]{"ln", "language-dialect", syntax.LangAuto}
+	posix      = &multiFlag[bool]{"p", "posix", false}
+	filename   = &multiFlag[string]{"", "filename", ""}
+	expRecover = &multiFlag[int]{"", "exp.recover", 0}
 
 	indent      = &multiFlag[uint]{"i", "indent", 0}
 	binNext     = &multiFlag[bool]{"bn", "binary-next-line", false}
@@ -81,7 +82,7 @@ var (
 
 	allFlags = []any{
 		versionFlag, list, write, simplify, minify, find, diff, applyIgnore,
-		lang, posix, filename,
+		lang, posix, filename, expRecover,
 		indent, binNext, caseIndent, spaceRedirs, keepPadding, funcNext, toJSON, fromJSON,
 	}
 )
@@ -113,6 +114,13 @@ func init() {
 			if name := f.long; name != "" {
 				flag.StringVar(&f.val, name, f.val, "")
 			}
+		case *multiFlag[int]:
+			if name := f.short; name != "" {
+				flag.IntVar(&f.val, name, f.val, "")
+			}
+			if name := f.long; name != "" {
+				flag.IntVar(&f.val, name, f.val, "")
+			}
 		case *multiFlag[uint]:
 			if name := f.short; name != "" {
 				flag.UintVar(&f.val, name, f.val, "")
@@ -134,10 +142,6 @@ func init() {
 }
 
 func main() {
-	os.Exit(main1())
-}
-
-func main1() int {
 	flag.Usage = func() {
 		fmt.Fprint(os.Stderr, `usage: shfmt [flags] [path ...]
 
@@ -194,19 +198,19 @@ For more information and to report bugs, see https://github.com/mvdan/sh.
 			version = mod.Version
 		}
 		fmt.Println(version)
-		return 0
+		return
 	}
 	if posix.val && lang.val != syntax.LangAuto {
 		fmt.Fprintf(os.Stderr, "-p and -ln=lang cannot coexist\n")
-		return 1
+		os.Exit(1)
 	}
 	if list.val != "true" && list.val != "false" && list.val != "0" {
 		fmt.Fprintf(os.Stderr, "only -l and -l=0 allowed\n")
-		return 1
+		os.Exit(1)
 	}
 	if find.val != "true" && find.val != "false" && find.val != "0" {
 		fmt.Fprintf(os.Stderr, "only -f and -f=0 allowed\n")
-		return 1
+		os.Exit(1)
 	}
 	if minify.val {
 		simplify.val = true
@@ -227,6 +231,8 @@ For more information and to report bugs, see https://github.com/mvdan/sh.
 	parser = syntax.NewParser(syntax.KeepComments(true))
 	printer = syntax.NewPrinter(syntax.Minify(minify.val))
 
+	syntax.RecoverErrors(expRecover.val)(parser)
+
 	if !useEditorConfig {
 		if posix.val {
 			// -p equals -ln=posix
@@ -261,17 +267,17 @@ For more information and to report bugs, see https://github.com/mvdan/sh.
 			if err != errChangedWithDiff {
 				fmt.Fprintln(os.Stderr, err)
 			}
-			return 1
+			os.Exit(1)
 		}
-		return 0
+		return
 	}
 	if filename.val != "" {
 		fmt.Fprintln(os.Stderr, "-filename can only be used with stdin")
-		return 1
+		os.Exit(1)
 	}
 	if toJSON.val {
 		fmt.Fprintln(os.Stderr, "--to-json can only be used with stdin")
-		return 1
+		os.Exit(1)
 	}
 	status := 0
 	for _, path := range flag.Args() {
@@ -309,7 +315,7 @@ For more information and to report bugs, see https://github.com/mvdan/sh.
 			status = 1
 		}
 	}
-	return status
+	os.Exit(status)
 }
 
 var errChangedWithDiff = fmt.Errorf("")
diff --git a/go.mod b/go.mod
index 4648239e..a6df6d02 100644
--- a/go.mod
+++ b/go.mod
@@ -7,8 +7,7 @@ require (
 	github.com/go-quicktest/qt v1.101.0
 	github.com/google/go-cmp v0.6.0
 	github.com/google/renameio/v2 v2.0.0
-	github.com/muesli/cancelreader v0.2.2
-	github.com/rogpeppe/go-internal v1.13.1
+	github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a
 	golang.org/x/sync v0.9.0
 	golang.org/x/sys v0.27.0
 	golang.org/x/term v0.26.0
diff --git a/go.sum b/go.sum
index 232becea..cca62cda 100644
--- a/go.sum
+++ b/go.sum
@@ -11,12 +11,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
-github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
 github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
 github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
 github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
+github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
 golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
 golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
diff --git a/interp/api.go b/interp/api.go
index dfc78a3d..a3a9c8d7 100644
--- a/interp/api.go
+++ b/interp/api.go
@@ -454,10 +454,15 @@ func stdinFile(r io.Reader) (*os.File, error) {
 // standard error. If out or err are nil, they default to a writer that discards
 // the output.
 //
-// Note that providing a non-nil standard input other than [os.File] will require
+// Note that providing a non-nil standard input other than [*os.File] will require
 // an [os.Pipe] and spawning a goroutine to copy into it,
 // as an [os.File] is the only way to share a reader with subprocesses.
+// This may cause the interpreter to consume the entire reader.
 // See [os/exec.Cmd.Stdin].
+//
+// When providing an [*os.File] as standard input, consider using an [os.Pipe]
+// as it has the best chance to support cancellable reads via [os.File.SetReadDeadline],
+// so that cancelling the runner's context can stop a blocked standard input read.
 func StdIO(in io.Reader, out, err io.Writer) RunnerOption {
 	return func(r *Runner) error {
 		stdin, _err := stdinFile(in)
diff --git a/interp/builtin.go b/interp/builtin.go
index fcfcea94..4b7e1b03 100644
--- a/interp/builtin.go
+++ b/interp/builtin.go
@@ -10,15 +10,13 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"io"
 	"os"
 	"path/filepath"
 	"strconv"
 	"strings"
-	"sync"
 	"syscall"
+	"time"
 
-	"github.com/muesli/cancelreader"
 	"golang.org/x/term"
 
 	"mvdan.cc/sh/v3/expand"
@@ -943,43 +941,22 @@ func (r *Runner) readLine(ctx context.Context, raw bool) ([]byte, error) {
 	var line []byte
 	esc := false
 
-	stdin := io.Reader(r.stdin)
-	// [cancelreader.NewReader] may fail under some circumstances, such as r.stdin being
-	// a regular file on Linux, in which case epoll returns an "operation not permitted" error
-	// given that regular files can always be read immediately. Polling them makes no sense.
-	// As such, if cancelreader fails, fall back to no cancellation, meaning this is best-effort.
-	//
-	// TODO: it would be nice if the cancelreader library classified errors so that we could
-	// safely handle "this file does not need polling" by skipping the polling as we do below
-	// but still fail on other errors, which may be unexpected or hide bugs.
-	// See the upstream issue: https://github.com/muesli/cancelreader/issues/23
-	if cr, err := cancelreader.NewReader(r.stdin); err == nil {
-		done := make(chan struct{})
-		var wg sync.WaitGroup
-		wg.Add(1)
-		go func() {
-			select {
-			case <-ctx.Done():
-				cr.Cancel()
-			case <-done:
-			}
-			wg.Done()
-		}()
-		defer func() {
-			close(done)
-			wg.Wait()
-			// Could put the Close in the above goroutine, but if "read" is
-			// immediately called again, the Close might overlap with creating a
-			// new cancelreader. Want this cancelreader to be completely closed
-			// by the time readLine returns.
-			cr.Close()
-		}()
-		stdin = cr
-	}
-
+	stopc := make(chan struct{})
+	stop := context.AfterFunc(ctx, func() {
+		r.stdin.SetReadDeadline(time.Now())
+		close(stopc)
+	})
+	defer func() {
+		if !stop() {
+			// The AfterFunc was started.
+			// Wait for it to complete, and reset the file's deadline.
+			<-stopc
+			r.stdin.SetReadDeadline(time.Time{})
+		}
+	}()
 	for {
 		var buf [1]byte
-		n, err := stdin.Read(buf[:])
+		n, err := r.stdin.Read(buf[:])
 		if n > 0 {
 			b := buf[0]
 			switch {
diff --git a/interp/handler.go b/interp/handler.go
index c617328e..edac099a 100644
--- a/interp/handler.go
+++ b/interp/handler.go
@@ -109,25 +109,18 @@ func DefaultExecHandler(killTimeout time.Duration) ExecHandlerFunc {
 
 		err = cmd.Start()
 		if err == nil {
-			if done := ctx.Done(); done != nil {
-				go func() {
-					<-done
-
-					if killTimeout <= 0 || runtime.GOOS == "windows" {
-						_ = cmd.Process.Signal(os.Kill)
-						return
-					}
-
-					// TODO: don't temporarily leak this goroutine
-					// if the program stops itself with the
-					// interrupt.
-					go func() {
-						time.Sleep(killTimeout)
-						_ = cmd.Process.Signal(os.Kill)
-					}()
-					_ = cmd.Process.Signal(os.Interrupt)
-				}()
-			}
+			stopf := context.AfterFunc(ctx, func() {
+				if killTimeout <= 0 || runtime.GOOS == "windows" {
+					_ = cmd.Process.Signal(os.Kill)
+					return
+				}
+				_ = cmd.Process.Signal(os.Interrupt)
+				// TODO: don't sleep in this goroutine if the program
+				// stops itself with the interrupt above.
+				time.Sleep(killTimeout)
+				_ = cmd.Process.Signal(os.Kill)
+			})
+			defer stopf()
 
 			err = cmd.Wait()
 		}
diff --git a/pattern/pattern.go b/pattern/pattern.go
index 99fd0ef3..23bf29fc 100644
--- a/pattern/pattern.go
+++ b/pattern/pattern.go
@@ -282,8 +282,8 @@ func charClass(s string) (string, error) {
 // For example, HasMeta(`foo\*bar`) returns false, but HasMeta(`foo*bar`)
 // returns true.
 //
-// This can be useful to avoid extra work, like TranslatePattern. Note that this
-// function cannot be used to avoid QuotePattern, as backslashes are quoted by
+// This can be useful to avoid extra work, like [Regexp]. Note that this
+// function cannot be used to avoid [QuoteMeta], as backslashes are quoted by
 // that function but ignored here.
 func HasMeta(pat string, mode Mode) bool {
 	for i := 0; i < len(pat); i++ {
diff --git a/syntax/nodes.go b/syntax/nodes.go
index c900c074..84057515 100644
--- a/syntax/nodes.go
+++ b/syntax/nodes.go
@@ -70,10 +70,16 @@ type Pos struct {
 	offs, lineCol uint32
 }
 
-// We used to split line and column numbers evenly in 16 bits, but line numbers
-// are significantly more important in practice. Use more bits for them.
 const (
-	offsetMax = math.MaxUint32
+	// Offsets use 32 bits for a reasonable amount of precision.
+	// We reserve a few of the highest values to represent types of invalid positions.
+	// We leave some space before the real uint32 maximum so that we can easily detect
+	// when arithmetic on invalid positions is done by mistake.
+	offsetRecovered = math.MaxUint32 - 10
+	offsetMax       = math.MaxUint32 - 11
+
+	// We used to split line and column numbers evenly in 16 bits, but line numbers
+	// are significantly more important in practice. Use more bits for them.
 
 	lineBitSize = 18
 	lineMax     = (1 << lineBitSize) - 1
@@ -109,20 +115,26 @@ func NewPos(offset, line, column uint) Pos {
 }
 
 // Offset returns the byte offset of the position in the original source file.
-// Byte offsets start at 0.
+// Byte offsets start at 0. Invalid positions always report the offset 0.
 //
 // Offset has basic protection against overflows; if an input is too large,
 // offset numbers will stop increasing past a very large number.
-func (p Pos) Offset() uint { return uint(p.offs) }
+func (p Pos) Offset() uint {
+	if p.offs > offsetMax {
+		return 0 // invalid
+	}
+	return uint(p.offs)
+}
 
 // Line returns the line number of the position, starting at 1.
+// Invalid positions always report the line number 0.
 //
 // Line is protected against overflows; if an input has too many lines, extra
 // lines will have a line number of 0, rendered as "?" by [Pos.String].
 func (p Pos) Line() uint { return uint(p.lineCol >> colBitSize) }
 
 // Col returns the column number of the position, starting at 1. It counts in
-// bytes.
+// bytes. Invalid positions always report the column number 0.
 //
 // Col is protected against overflows; if an input line has too many columns,
 // extra columns will have a column number of 0, rendered as "?" by [Pos.String].
@@ -147,13 +159,33 @@ func (p Pos) String() string {
 // IsValid reports whether the position contains useful position information.
 // Some positions returned via [Parse] may be invalid: for example, [Stmt.Semicolon]
 // will only be valid if a statement contained a closing token such as ';'.
-func (p Pos) IsValid() bool { return p != Pos{} }
+//
+// Recovered positions, as reported by [Pos.IsRecovered], are not considered valid
+// given that they don't contain position information.
+func (p Pos) IsValid() bool {
+	return p.offs <= offsetMax && p.lineCol != 0
+}
+
+var recoveredPos = Pos{offs: offsetRecovered}
+
+// IsRecovered reports whether the position that the token or node belongs to
+// was missing in the original input and recovered via [RecoverErrors].
+func (p Pos) IsRecovered() bool { return p == recoveredPos }
 
 // After reports whether the position p is after p2. It is a more expressive
 // version of p.Offset() > p2.Offset().
-func (p Pos) After(p2 Pos) bool { return p.offs > p2.offs }
+// It always returns false if p is an invalid position.
+func (p Pos) After(p2 Pos) bool {
+	if !p.IsValid() {
+		return false
+	}
+	return p.offs > p2.offs
+}
 
 func posAddCol(p Pos, n int) Pos {
+	if !p.IsValid() {
+		return p
+	}
 	// TODO: guard against overflows
 	p.lineCol += uint32(n)
 	p.offs += uint32(n)
diff --git a/syntax/parser.go b/syntax/parser.go
index 0bc10e86..17541b29 100644
--- a/syntax/parser.go
+++ b/syntax/parser.go
@@ -143,6 +143,26 @@ func StopAt(word string) ParserOption {
 	return func(p *Parser) { p.stopAt = []byte(word) }
 }
 
+// RecoverErrors allows the parser to skip up to a maximum number of
+// errors in the given input on a best-effort basis.
+// This can be useful to tab-complete an interactive shell prompt,
+// or when providing diagnostics on slightly incomplete shell source.
+//
+// Currently, this only helps with mandatory tokens from the shell grammar
+// which are not present in the input. They result in position fields
+// or nodes whose position report [Pos.IsRecovered] as true.
+//
+// For example, given the input
+//
+//	(foo |
+//
+// the result will contain two recovered positions; first, the pipe requires
+// a statement to follow, and as [Stmt.Pos] reports, the entire node is recovered.
+// Second, the subshell needs to be closed, so [Subshell.Rparen] is recovered.
+func RecoverErrors(maximum int) ParserOption {
+	return func(p *Parser) { p.recoverErrorsMax = maximum }
+}
+
 // NewParser allocates a new [Parser] and applies any number of options.
 func NewParser(options ...ParserOption) *Parser {
 	p := &Parser{}
@@ -364,6 +384,9 @@ type Parser struct {
 
 	stopAt []byte
 
+	recoveredErrors  int
+	recoverErrorsMax int
+
 	forbidNested bool
 
 	// list of pending heredoc bodies
@@ -422,6 +445,7 @@ func (p *Parser) reset() {
 	p.err, p.readErr = nil, nil
 	p.quote, p.forbidNested = noState, false
 	p.openStmts = 0
+	p.recoveredErrors = 0
 	p.heredocs, p.buriedHdocs = p.heredocs[:0], 0
 	p.hdocStops = nil
 	p.parsingDoc = false
@@ -649,6 +673,14 @@ func (p *Parser) gotRsrv(val string) (Pos, bool) {
 	return pos, false
 }
 
+func (p *Parser) recoverError() bool {
+	if p.recoveredErrors < p.recoverErrorsMax {
+		p.recoveredErrors++
+		return true
+	}
+	return false
+}
+
 func readableStr(s string) string {
 	// don't quote tokens like & or }
 	if s != "" && s[0] >= 'a' && s[0] <= 'z' {
@@ -675,6 +707,9 @@ func (p *Parser) follow(lpos Pos, left string, tok token) {
 func (p *Parser) followRsrv(lpos Pos, left, val string) Pos {
 	pos, ok := p.gotRsrv(val)
 	if !ok {
+		if p.recoverError() {
+			return recoveredPos
+		}
 		p.followErr(lpos, left, fmt.Sprintf("%q", val))
 	}
 	return pos
@@ -687,6 +722,9 @@ func (p *Parser) followStmts(left string, lpos Pos, stops ...string) ([]*Stmt, [
 	newLine := p.got(_Newl)
 	stmts, last := p.stmtList(stops...)
 	if len(stmts) < 1 && !newLine {
+		if p.recoverError() {
+			return []*Stmt{{Position: recoveredPos}}, nil
+		}
 		p.followErr(lpos, left, "a statement list")
 	}
 	return stmts, last
@@ -695,6 +733,9 @@ func (p *Parser) followStmts(left string, lpos Pos, stops ...string) ([]*Stmt, [
 func (p *Parser) followWordTok(tok token, pos Pos) *Word {
 	w := p.getWord()
 	if w == nil {
+		if p.recoverError() {
+			return p.wordOne(&Lit{ValuePos: recoveredPos})
+		}
 		p.followErr(pos, tok.String(), "a word")
 	}
 	return w
@@ -703,6 +744,9 @@ func (p *Parser) followWordTok(tok token, pos Pos) *Word {
 func (p *Parser) stmtEnd(n Node, start, end string) Pos {
 	pos, ok := p.gotRsrv(end)
 	if !ok {
+		if p.recoverError() {
+			return recoveredPos
+		}
 		p.posErr(n.Pos(), "%s statement must end with %q", start, end)
 	}
 	return pos
@@ -721,6 +765,9 @@ func (p *Parser) matchingErr(lpos Pos, left, right any) {
 func (p *Parser) matched(lpos Pos, left, right token) Pos {
 	pos := p.pos
 	if !p.got(right) {
+		if p.recoverError() {
+			return recoveredPos
+		}
 		p.matchingErr(lpos, left, right)
 	}
 	return pos
@@ -1107,6 +1154,10 @@ func (p *Parser) wordPart() WordPart {
 				p.litBs = append(p.litBs, '\\', '\n')
 			case utf8.RuneSelf:
 				p.tok = _EOF
+				if p.recoverError() {
+					sq.Right = recoveredPos
+					return sq
+				}
 				p.quoteErr(sq.Pos(), sglQuote)
 				return nil
 			}
@@ -1144,7 +1195,11 @@ func (p *Parser) wordPart() WordPart {
 		// Like above, the lexer didn't call p.rune for us.
 		p.rune()
 		if !p.got(bckQuote) {
-			p.quoteErr(cs.Pos(), bckQuote)
+			if p.recoverError() {
+				cs.Right = recoveredPos
+			} else {
+				p.quoteErr(cs.Pos(), bckQuote)
+			}
 		}
 		return cs
 	case globQuest, globStar, globPlus, globAt, globExcl:
@@ -1194,7 +1249,11 @@ func (p *Parser) dblQuoted() *DblQuoted {
 	p.quote = old
 	q.Right = p.pos
 	if !p.got(dblQuote) {
-		p.quoteErr(q.Pos(), dblQuote)
+		if p.recoverError() {
+			q.Right = recoveredPos
+		} else {
+			p.quoteErr(q.Pos(), dblQuote)
+		}
 	}
 	return q
 }
@@ -1661,8 +1720,12 @@ func (p *Parser) getStmt(readEnd, binCmd, fnBody bool) *Stmt {
 		p.got(_Newl)
 		b.Y = p.getStmt(false, true, false)
 		if b.Y == nil || p.err != nil {
-			p.followErr(b.OpPos, b.Op.String(), "a statement")
-			return nil
+			if p.recoverError() {
+				b.Y = &Stmt{Position: recoveredPos}
+			} else {
+				p.followErr(b.OpPos, b.Op.String(), "a statement")
+				return nil
+			}
 		}
 		s = &Stmt{Position: s.Position}
 		s.Cmd = b
@@ -1834,8 +1897,12 @@ func (p *Parser) gotStmtPipe(s *Stmt, binCmd bool) *Stmt {
 		p.next()
 		p.got(_Newl)
 		if b.Y = p.gotStmtPipe(&Stmt{Position: p.pos}, true); b.Y == nil || p.err != nil {
-			p.followErr(b.OpPos, b.Op.String(), "a statement")
-			break
+			if p.recoverError() {
+				b.Y = &Stmt{Position: recoveredPos}
+			} else {
+				p.followErr(b.OpPos, b.Op.String(), "a statement")
+				break
+			}
 		}
 		s = &Stmt{Position: s.Position}
 		s.Cmd = b
@@ -1876,9 +1943,11 @@ func (p *Parser) block(s *Stmt) {
 	b := &Block{Lbrace: p.pos}
 	p.next()
 	b.Stmts, b.Last = p.stmtList("}")
-	pos, ok := p.gotRsrv("}")
-	b.Rbrace = pos
-	if !ok {
+	if pos, ok := p.gotRsrv("}"); ok {
+		b.Rbrace = pos
+	} else if p.recoverError() {
+		b.Rbrace = recoveredPos
+	} else {
 		p.matchingErr(b.Lbrace, "{", "}")
 	}
 	s.Cmd = b
diff --git a/syntax/parser_arithm.go b/syntax/parser_arithm.go
index c8567b52..d04f3d89 100644
--- a/syntax/parser_arithm.go
+++ b/syntax/parser_arithm.go
@@ -343,6 +343,9 @@ func (p *Parser) matchedArithm(lpos Pos, left, right token) {
 
 func (p *Parser) arithmEnd(ltok token, lpos Pos, old saveState) Pos {
 	if !p.peekArithmEnd() {
+		if p.recoverError() {
+			return recoveredPos
+		}
 		p.arithmMatchingErr(lpos, ltok, dblRightParen)
 	}
 	p.rune()
diff --git a/syntax/typedjson/json.go b/syntax/typedjson/json.go
index 2400edea..5720cffc 100644
--- a/syntax/typedjson/json.go
+++ b/syntax/typedjson/json.go
@@ -173,6 +173,7 @@ type exportedPos struct {
 }
 
 func encodePos(encPtr reflect.Value, val syntax.Pos) {
+	// TODO: perhaps we should encode recovered positions, as that is still useful information.
 	if !val.IsValid() {
 		return
 	}
diff --git a/syntax/walk.go b/syntax/walk.go
index 85d66924..b16f31e5 100644
--- a/syntax/walk.go
+++ b/syntax/walk.go
@@ -9,21 +9,6 @@ import (
 	"reflect"
 )
 
-func walkStmts(stmts []*Stmt, last []Comment, f func(Node) bool) {
-	for _, s := range stmts {
-		Walk(s, f)
-	}
-	for _, c := range last {
-		Walk(&c, f)
-	}
-}
-
-func walkWords(words []*Word, f func(Node) bool) {
-	for _, w := range words {
-		Walk(w, f)
-	}
-}
-
 // Walk traverses a syntax tree in depth-first order: It starts by calling
 // f(node); node must not be nil. If f returns true, Walk invokes f
 // recursively for each of the non-nil children of node, followed by
@@ -35,7 +20,8 @@ func Walk(node Node, f func(Node) bool) {
 
 	switch node := node.(type) {
 	case *File:
-		walkStmts(node.Stmts, node.Last, f)
+		walkList(node.Stmts, f)
+		walkComments(node.Last, f)
 	case *Comment:
 	case *Stmt:
 		for _, c := range node.Comments {
@@ -48,64 +34,47 @@ func Walk(node Node, f func(Node) bool) {
 		if node.Cmd != nil {
 			Walk(node.Cmd, f)
 		}
-		for _, r := range node.Redirs {
-			Walk(r, f)
-		}
+		walkList(node.Redirs, f)
 	case *Assign:
-		if node.Name != nil {
-			Walk(node.Name, f)
-		}
-		if node.Value != nil {
-			Walk(node.Value, f)
-		}
-		if node.Index != nil {
-			Walk(node.Index, f)
-		}
-		if node.Array != nil {
-			Walk(node.Array, f)
-		}
+		walkNilable(node.Name, f)
+		walkNilable(node.Value, f)
+		walkNilable(node.Index, f)
+		walkNilable(node.Array, f)
 	case *Redirect:
-		if node.N != nil {
-			Walk(node.N, f)
-		}
+		walkNilable(node.N, f)
 		Walk(node.Word, f)
-		if node.Hdoc != nil {
-			Walk(node.Hdoc, f)
-		}
+		walkNilable(node.Hdoc, f)
 	case *CallExpr:
-		for _, a := range node.Assigns {
-			Walk(a, f)
-		}
-		walkWords(node.Args, f)
+		walkList(node.Assigns, f)
+		walkList(node.Args, f)
 	case *Subshell:
-		walkStmts(node.Stmts, node.Last, f)
+		walkList(node.Stmts, f)
+		walkComments(node.Last, f)
 	case *Block:
-		walkStmts(node.Stmts, node.Last, f)
+		walkList(node.Stmts, f)
+		walkComments(node.Last, f)
 	case *IfClause:
-		walkStmts(node.Cond, node.CondLast, f)
-		walkStmts(node.Then, node.ThenLast, f)
-		if node.Else != nil {
-			Walk(node.Else, f)
-		}
+		walkList(node.Cond, f)
+		walkComments(node.CondLast, f)
+		walkList(node.Then, f)
+		walkComments(node.ThenLast, f)
+		walkNilable(node.Else, f)
 	case *WhileClause:
-		walkStmts(node.Cond, node.CondLast, f)
-		walkStmts(node.Do, node.DoLast, f)
+		walkList(node.Cond, f)
+		walkComments(node.CondLast, f)
+		walkList(node.Do, f)
+		walkComments(node.DoLast, f)
 	case *ForClause:
 		Walk(node.Loop, f)
-		walkStmts(node.Do, node.DoLast, f)
+		walkList(node.Do, f)
+		walkComments(node.DoLast, f)
 	case *WordIter:
 		Walk(node.Name, f)
-		walkWords(node.Items, f)
+		walkList(node.Items, f)
 	case *CStyleLoop:
-		if node.Init != nil {
-			Walk(node.Init, f)
-		}
-		if node.Cond != nil {
-			Walk(node.Cond, f)
-		}
-		if node.Post != nil {
-			Walk(node.Post, f)
-		}
+		walkNilable(node.Init, f)
+		walkNilable(node.Cond, f)
+		walkNilable(node.Post, f)
 	case *BinaryCmd:
 		Walk(node.X, f)
 		Walk(node.Y, f)
@@ -113,32 +82,23 @@ func Walk(node Node, f func(Node) bool) {
 		Walk(node.Name, f)
 		Walk(node.Body, f)
 	case *Word:
-		for _, wp := range node.Parts {
-			Walk(wp, f)
-		}
+		walkList(node.Parts, f)
 	case *Lit:
 	case *SglQuoted:
 	case *DblQuoted:
-		for _, wp := range node.Parts {
-			Walk(wp, f)
-		}
+		walkList(node.Parts, f)
 	case *CmdSubst:
-		walkStmts(node.Stmts, node.Last, f)
+		walkList(node.Stmts, f)
+		walkComments(node.Last, f)
 	case *ParamExp:
 		Walk(node.Param, f)
-		if node.Index != nil {
-			Walk(node.Index, f)
-		}
+		walkNilable(node.Index, f)
 		if node.Repl != nil {
-			if node.Repl.Orig != nil {
-				Walk(node.Repl.Orig, f)
-			}
-			if node.Repl.With != nil {
-				Walk(node.Repl.With, f)
-			}
+			walkNilable(node.Repl.Orig, f)
+			walkNilable(node.Repl.With, f)
 		}
-		if node.Exp != nil && node.Exp.Word != nil {
-			Walk(node.Exp.Word, f)
+		if node.Exp != nil {
+			walkNilable(node.Exp.Word, f)
 		}
 	case *ArithmExp:
 		Walk(node.X, f)
@@ -160,12 +120,8 @@ func Walk(node Node, f func(Node) bool) {
 		Walk(node.X, f)
 	case *CaseClause:
 		Walk(node.Word, f)
-		for _, ci := range node.Items {
-			Walk(ci, f)
-		}
-		for _, c := range node.Last {
-			Walk(&c, f)
-		}
+		walkList(node.Items, f)
+		walkComments(node.Last, f)
 	case *CaseItem:
 		for _, c := range node.Comments {
 			if c.Pos().After(node.Pos()) {
@@ -174,21 +130,16 @@ func Walk(node Node, f func(Node) bool) {
 			}
 			Walk(&c, f)
 		}
-		walkWords(node.Patterns, f)
-		walkStmts(node.Stmts, node.Last, f)
+		walkList(node.Patterns, f)
+		walkList(node.Stmts, f)
+		walkComments(node.Last, f)
 	case *TestClause:
 		Walk(node.X, f)
 	case *DeclClause:
-		for _, a := range node.Args {
-			Walk(a, f)
-		}
+		walkList(node.Args, f)
 	case *ArrayExpr:
-		for _, el := range node.Elems {
-			Walk(el, f)
-		}
-		for _, c := range node.Last {
-			Walk(&c, f)
-		}
+		walkList(node.Elems, f)
+		walkComments(node.Last, f)
 	case *ArrayElem:
 		for _, c := range node.Comments {
 			if c.Pos().After(node.Pos()) {
@@ -197,29 +148,20 @@ func Walk(node Node, f func(Node) bool) {
 			}
 			Walk(&c, f)
 		}
-		if node.Index != nil {
-			Walk(node.Index, f)
-		}
-		if node.Value != nil {
-			Walk(node.Value, f)
-		}
+		walkNilable(node.Index, f)
+		walkNilable(node.Value, f)
 	case *ExtGlob:
 		Walk(node.Pattern, f)
 	case *ProcSubst:
-		walkStmts(node.Stmts, node.Last, f)
+		walkList(node.Stmts, f)
+		walkComments(node.Last, f)
 	case *TimeClause:
-		if node.Stmt != nil {
-			Walk(node.Stmt, f)
-		}
+		walkNilable(node.Stmt, f)
 	case *CoprocClause:
-		if node.Name != nil {
-			Walk(node.Name, f)
-		}
+		walkNilable(node.Name, f)
 		Walk(node.Stmt, f)
 	case *LetClause:
-		for _, expr := range node.Exprs {
-			Walk(expr, f)
-		}
+		walkList(node.Exprs, f)
 	case *TestDecl:
 		Walk(node.Description, f)
 		Walk(node.Body, f)
@@ -230,6 +172,29 @@ func Walk(node Node, f func(Node) bool) {
 	f(nil)
 }
 
+type nilableNode interface {
+	Node
+	comparable // pointer nodes, which can be compared to nil
+}
+
+func walkNilable[N nilableNode](node N, f func(Node) bool) {
+	var zero N // nil
+	if node != zero {
+		Walk(node, f)
+	}
+}
+func walkList[N Node](list []N, f func(Node) bool) {
+	for _, node := range list {
+		Walk(node, f)
+	}
+}
+func walkComments(list []Comment, f func(Node) bool) {
+	// Note that []Comment does not satisfy the generic constraint []Node.
+	for i := range list {
+		Walk(&list[i], f)
+	}
+}
+
 // DebugPrint prints the provided syntax tree, spanning multiple lines and with
 // indentation. Can be useful to investigate the content of a syntax tree.
 func DebugPrint(w io.Writer, node Node) error {
@@ -292,6 +257,10 @@ func (p *debugPrinter) print(x reflect.Value) {
 
 	case reflect.Struct:
 		if v, ok := x.Interface().(Pos); ok {
+			if v.IsRecovered() {
+				p.printf("<recovered>")
+				return
+			}
 			p.printf("%v:%v", v.Line(), v.Col())
 			return
 		}


Test patch:
diff --git a/cmd/shfmt/main_test.go b/cmd/shfmt/main_test.go
index 29686f3e..37531072 100644
--- a/cmd/shfmt/main_test.go
+++ b/cmd/shfmt/main_test.go
@@ -5,7 +5,6 @@ package main
 
 import (
 	"flag"
-	"os"
 	"path/filepath"
 	"testing"
 
@@ -13,9 +12,9 @@ import (
 )
 
 func TestMain(m *testing.M) {
-	os.Exit(testscript.RunMain(m, map[string]func() int{
-		"shfmt": main1,
-	}))
+	testscript.Main(m, map[string]func(){
+		"shfmt": main,
+	})
 }
 
 var update = flag.Bool("u", false, "update testscript output files")
diff --git a/interp/interp_test.go b/interp/interp_test.go
index d2e5e4c1..1475f55e 100644
--- a/interp/interp_test.go
+++ b/interp/interp_test.go
@@ -4029,32 +4029,31 @@ func TestRunnerContext(t *testing.T) {
 	}
 }
 
-func TestCancelreader(t *testing.T) {
+func TestCancelBlockedStdinRead(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		// TODO: Why is this? The [os.File.SetReadDeadline] docs seem to imply that it should work
+		// across all major platforms, and the file polling  implementation seems to be
+		// for all posix platforms including Windows.
+		// Our previous logic and tests with muesli/cancelreader did not test an os.Pipe
+		// on Windows either, so skipping here is not any worse.
+		t.Skip("os.Pipe on windows appears to not support cancellable reads")
+	}
 	t.Parallel()
 
 	p := syntax.NewParser()
 	file := parse(t, p, "read x")
 	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
-	// Make the linter happy, even though we deliberately wait for the
-	// timeout.
+	// Make the linter happy, even though we deliberately wait for the timeout.
 	defer cancel()
 
-	var stdinRead *os.File
-	if runtime.GOOS == "windows" {
-		// On Windows, the cancelreader only works on stdin
-		stdinRead = os.Stdin
-	} else {
-		var stdinWrite *os.File
-		var err error
-		stdinRead, stdinWrite, err = os.Pipe()
-		if err != nil {
-			t.Fatalf("Error calling os.Pipe: %v", err)
-		}
-		defer func() {
-			stdinWrite.Close()
-			stdinRead.Close()
-		}()
+	stdinRead, stdinWrite, err := os.Pipe()
+	if err != nil {
+		t.Fatalf("Error calling os.Pipe: %v", err)
 	}
+	defer func() {
+		stdinWrite.Close()
+		stdinRead.Close()
+	}()
 	r, _ := interp.New(interp.StdIO(stdinRead, nil, nil))
 	now := time.Now()
 	errChan := make(chan error)
diff --git a/syntax/parser_test.go b/syntax/parser_test.go
index 50374656..416342c8 100644
--- a/syntax/parser_test.go
+++ b/syntax/parser_test.go
@@ -10,6 +10,7 @@ import (
 	"io"
 	"os"
 	"os/exec"
+	"reflect"
 	"regexp"
 	"strings"
 	"sync"
@@ -2516,3 +2517,175 @@ func TestPosEdgeCases(t *testing.T) {
 	qt.Check(t, qt.Equals(f.Stmts[1].Pos().String(), "2:2"))
 	qt.Check(t, qt.Equals(f.Stmts[1].End().String(), "2:9"))
 }
+
+func TestParseRecoverErrors(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		src string
+
+		wantErr     bool
+		wantMissing int
+	}{
+		{src: "foo;"},
+		{src: "foo"},
+		{
+			src:         "'incomp",
+			wantMissing: 1,
+		},
+		{
+			src:         "foo; 'incomp",
+			wantMissing: 1,
+		},
+		{
+			src:         "{ incomp",
+			wantMissing: 1,
+		},
+		{
+			src:         "(incomp",
+			wantMissing: 1,
+		},
+		{
+			src:         "(incomp; foo",
+			wantMissing: 1,
+		},
+		{
+			src:         "$(incomp",
+			wantMissing: 1,
+		},
+		{
+			src:         "((incomp",
+			wantMissing: 1,
+		},
+		{
+			src:         "$((incomp",
+			wantMissing: 1,
+		},
+		{
+			src:         "if foo",
+			wantMissing: 3,
+		},
+		{
+			src:         "if foo; then bar",
+			wantMissing: 1,
+		},
+		{
+			src:         "for i in 1 2 3; echo $i; done",
+			wantMissing: 1,
+		},
+		{
+			src:         `"incomp`,
+			wantMissing: 1,
+		},
+		{
+			src:         "`incomp",
+			wantMissing: 1,
+		},
+		{
+			src:         "incomp >",
+			wantMissing: 1,
+		},
+		{
+			src:         "${incomp",
+			wantMissing: 1,
+		},
+		{
+			src:         "incomp | ",
+			wantMissing: 1,
+		},
+		{
+			src:         "incomp || ",
+			wantMissing: 1,
+		},
+		{
+			src:         "incomp && ",
+			wantMissing: 1,
+		},
+		{
+			src:         `(one | { two >`,
+			wantMissing: 3,
+		},
+		{
+			src:         `(one > ; two | ); { three`,
+			wantMissing: 3,
+		},
+		{
+			src:     "badsyntax)",
+			wantErr: true,
+		},
+	}
+	parser := NewParser(RecoverErrors(3))
+	printer := NewPrinter()
+	for _, tc := range tests {
+		t.Run("", func(t *testing.T) {
+			t.Logf("input: %s", tc.src)
+			r := strings.NewReader(tc.src)
+			f, err := parser.Parse(r, "")
+			if tc.wantErr {
+				qt.Assert(t, qt.Not(qt.IsNil(err)))
+			} else {
+				qt.Assert(t, qt.IsNil(err))
+				switch len(f.Stmts) {
+				case 0:
+					t.Fatalf("result has no statements")
+				case 1:
+					if f.Stmts[0].Pos().IsRecovered() {
+						t.Fatalf("result is only a recovered statement")
+					}
+				}
+			}
+			qt.Assert(t, qt.Equals(countRecoveredPositions(reflect.ValueOf(f)), tc.wantMissing))
+
+			// Check that walking or printing the syntax tree still appears to work
+			// even when the input source was incomplete.
+			Walk(f, func(node Node) bool {
+				if node == nil {
+					return true
+				}
+				// Each position should either be valid, pointing to an offset within the input,
+				// or invalid, which could be due to the position being recovered.
+				for _, pos := range []Pos{node.Pos(), node.End()} {
+					qt.Assert(t, qt.IsFalse(pos.IsValid() && pos.IsRecovered()), qt.Commentf("positions cannot be valid and recovered"))
+					if !pos.IsValid() {
+						qt.Assert(t, qt.Equals(pos.Offset(), 0), qt.Commentf("invalid positions have no offset"))
+						qt.Assert(t, qt.Equals(pos.Line(), 0), qt.Commentf("invalid positions have no line"))
+						qt.Assert(t, qt.Equals(pos.Col(), 0), qt.Commentf("invalid positions have no column"))
+					}
+				}
+				return true
+			})
+			// Note that we don't particularly care about good formatting here.
+			printer.Print(io.Discard, f)
+		})
+	}
+}
+
+func countRecoveredPositions(x reflect.Value) int {
+	switch x.Kind() {
+	case reflect.Interface:
+		return countRecoveredPositions(x.Elem())
+	case reflect.Ptr:
+		if !x.IsNil() {
+			return countRecoveredPositions(x.Elem())
+		}
+	case reflect.Slice:
+		n := 0
+		for i := 0; i < x.Len(); i++ {
+			n += countRecoveredPositions(x.Index(i))
+		}
+		return n
+	case reflect.Struct:
+		if pos, ok := x.Interface().(Pos); ok {
+			if pos.IsRecovered() {
+				return 1
+			}
+			return 0
+		}
+		n := 0
+		for i := 0; i < x.NumField(); i++ {
+			n += countRecoveredPositions(x.Field(i))
+		}
+		return n
+	}
+	return 0
+}