modfile: scan ASCII brackets and commas as separate tokens

The characters '( ) [ ] { } ,' are now scanned as separate tokens,
even when they are preceded by non-whitespace "identifier"
characters. Previously, '( )' were scanned like this when preceded by
whitespace, but they could be in the middle of an identifier
token. None of these characters are allowed in module paths or
versions. They are allowed within file paths, so file paths containing
them will need to be quoted in the future. Using these characters
should not break ParseLax, since replace directives (the only directive
that allows files paths) are ignored by ParseLax.

Additionally, '(' is only treated as the beginning of a block if it
appears at the end of the line or is immediately followed by ')' at the
end of the line. ')' is treated as the end of a block if it
appears within a block at the beginning of a line.

Fixes golang/go#38167
Updates golang/go#38144
Updates golang/go#24031

Change-Id: I5a7fb522163802c3723d289cf0fbc5856ca075ec
Reviewed-on: https://go-review.googlesource.com/c/mod/+/226639
Reviewed-by: Bryan C. Mills <bcmills@google.com>
diff --git a/modfile/print.go b/modfile/print.go
index 3bbea38..524f930 100644
--- a/modfile/print.go
+++ b/modfile/print.go
@@ -138,16 +138,11 @@
 		p.printf(")")
 
 	case *Line:
-		sep := ""
-		for _, tok := range x.Token {
-			p.printf("%s%s", sep, tok)
-			sep = " "
-		}
+		p.tokens(x.Token)
 
 	case *LineBlock:
-		for _, tok := range x.Token {
-			p.printf("%s ", tok)
-		}
+		p.tokens(x.Token)
+		p.printf(" ")
 		p.expr(&x.LParen)
 		p.margin++
 		for _, l := range x.Line {
@@ -163,3 +158,17 @@
 	// reach the end of the line.
 	p.comment = append(p.comment, x.Comment().Suffix...)
 }
+
+func (p *printer) tokens(tokens []string) {
+	sep := ""
+	for _, t := range tokens {
+		if t == "," || t == ")" || t == "]" || t == "}" {
+			sep = ""
+		}
+		p.printf("%s%s", sep, t)
+		sep = " "
+		if t == "(" || t == "[" || t == "{" {
+			sep = ""
+		}
+	}
+}
diff --git a/modfile/read.go b/modfile/read.go
index 14c77b4..c1f2008 100644
--- a/modfile/read.go
+++ b/modfile/read.go
@@ -321,14 +321,13 @@
 // An input represents a single input file being parsed.
 type input struct {
 	// Lexing state.
-	filename  string    // name of input file, for errors
-	complete  []byte    // entire input
-	remaining []byte    // remaining input
-	token     []byte    // token being scanned
-	lastToken string    // most recently returned token, for error messages
-	pos       Position  // current input position
-	comments  []Comment // accumulated comments
-	endRule   int       // position of end of current rule
+	filename   string    // name of input file, for errors
+	complete   []byte    // entire input
+	remaining  []byte    // remaining input
+	tokenStart []byte    // token being scanned to end of input
+	token      token     // next token to be returned by lex, peek
+	pos        Position  // current input position
+	comments   []Comment // accumulated comments
 
 	// Parser state.
 	file        *FileSyntax // returned top-level syntax tree
@@ -350,11 +349,11 @@
 
 // parse parses the input file.
 func parse(file string, data []byte) (f *FileSyntax, err error) {
-	in := newInput(file, data)
 	// The parser panics for both routine errors like syntax errors
 	// and for programmer bugs like array index errors.
 	// Turn both into error returns. Catching bug panics is
 	// especially important when processing many files.
+	in := newInput(file, data)
 	defer func() {
 		if e := recover(); e != nil && e != &in.parseErrors {
 			in.parseErrors = append(in.parseErrors, Error{
@@ -368,6 +367,10 @@
 		}
 	}()
 
+	// Prime the lexer by reading in the first token. It will be available
+	// in the next peek() or lex() call.
+	in.readToken()
+
 	// Invoke the parser.
 	in.parseFile()
 	if len(in.parseErrors) > 0 {
@@ -382,12 +385,8 @@
 }
 
 // Error is called to report an error.
-// The reason s is often "syntax error".
 // Error does not return: it panics.
 func (in *input) Error(s string) {
-	if s == "syntax error" && in.lastToken != "" {
-		s += " near " + in.lastToken
-	}
 	in.parseErrors = append(in.parseErrors, Error{
 		Filename: in.filename,
 		Pos:      in.pos,
@@ -439,46 +438,68 @@
 	return int(r)
 }
 
-type symType struct {
+type token struct {
+	kind   tokenKind
 	pos    Position
 	endPos Position
 	text   string
 }
 
+type tokenKind int
+
+const (
+	_EOF tokenKind = -(iota + 1)
+	_EOLCOMMENT
+	_IDENT
+	_STRING
+	_COMMENT
+
+	// newlines and punctuation tokens are allowed as ASCII codes.
+)
+
+func (k tokenKind) isComment() bool {
+	return k == _COMMENT || k == _EOLCOMMENT
+}
+
+// isEOL returns whether a token terminates a line.
+func (k tokenKind) isEOL() bool {
+	return k == _EOF || k == _EOLCOMMENT || k == '\n'
+}
+
 // startToken marks the beginning of the next input token.
-// It must be followed by a call to endToken, once the token has
+// It must be followed by a call to endToken, once the token's text has
 // been consumed using readRune.
-func (in *input) startToken(sym *symType) {
-	in.token = in.remaining
-	sym.text = ""
-	sym.pos = in.pos
+func (in *input) startToken() {
+	in.tokenStart = in.remaining
+	in.token.text = ""
+	in.token.pos = in.pos
 }
 
 // endToken marks the end of an input token.
-// It records the actual token string in sym.text if the caller
-// has not done that already.
-func (in *input) endToken(sym *symType) {
-	if sym.text == "" {
-		tok := string(in.token[:len(in.token)-len(in.remaining)])
-		sym.text = tok
-		in.lastToken = sym.text
-	}
-	sym.endPos = in.pos
+// It records the actual token string in tok.text.
+func (in *input) endToken(kind tokenKind) {
+	in.token.kind = kind
+	text := string(in.tokenStart[:len(in.tokenStart)-len(in.remaining)])
+	in.token.text = text
+	in.token.endPos = in.pos
+}
+
+// peek returns the kind of the the next token returned by lex.
+func (in *input) peek() tokenKind {
+	return in.token.kind
 }
 
 // lex is called from the parser to obtain the next input token.
-// It returns the token value (either a rune like '+' or a symbolic token _FOR)
-// and sets val to the data associated with the token.
-// For all our input tokens, the associated data is
-// val.Pos (the position where the token begins)
-// and val.Token (the input string corresponding to the token).
-func (in *input) lex(sym *symType) int {
+func (in *input) lex() token {
+	tok := in.token
+	in.readToken()
+	return tok
+}
+
+// readToken lexes the next token from the text and stores it in in.token.
+func (in *input) readToken() {
 	// Skip past spaces, stopping at non-space or EOF.
-	countNL := 0 // number of newlines we've skipped past
 	for !in.eof() {
-		// Skip over spaces. Count newlines so we can give the parser
-		// information about where top-level blank lines are,
-		// for top-level comment assignment.
 		c := in.peekRune()
 		if c == ' ' || c == '\t' || c == '\r' {
 			in.readRune()
@@ -487,7 +508,7 @@
 
 		// Comment runs to end of line.
 		if in.peekPrefix("//") {
-			in.startToken(sym)
+			in.startToken()
 
 			// Is this comment the only thing on its line?
 			// Find the last \n before this // and see if it's all
@@ -500,26 +521,19 @@
 			// Consume comment.
 			for len(in.remaining) > 0 && in.readRune() != '\n' {
 			}
-			in.endToken(sym)
-
-			sym.text = strings.TrimRight(sym.text, "\n")
-			in.lastToken = "comment"
 
 			// If we are at top level (not in a statement), hand the comment to
 			// the parser as a _COMMENT token. The grammar is written
 			// to handle top-level comments itself.
 			if !suffix {
-				// Not in a statement. Tell parser about top-level comment.
-				return _COMMENT
+				in.endToken(_COMMENT)
+				return
 			}
 
 			// Otherwise, save comment for later attachment to syntax tree.
-			if countNL > 1 {
-				in.comments = append(in.comments, Comment{sym.pos, "", false})
-			}
-			in.comments = append(in.comments, Comment{sym.pos, sym.text, suffix})
-			countNL = 1
-			return _EOL
+			in.endToken(_EOLCOMMENT)
+			in.comments = append(in.comments, Comment{in.token.pos, in.token.text, suffix})
+			return
 		}
 
 		if in.peekPrefix("/*") {
@@ -531,35 +545,27 @@
 	}
 
 	// Found the beginning of the next token.
-	in.startToken(sym)
-	defer in.endToken(sym)
+	in.startToken()
 
 	// End of file.
 	if in.eof() {
-		in.lastToken = "EOF"
-		return _EOF
+		in.endToken(_EOF)
+		return
 	}
 
 	// Punctuation tokens.
 	switch c := in.peekRune(); c {
-	case '\n':
+	case '\n', '(', ')', '[', ']', '{', '}', ',':
 		in.readRune()
-		return c
-
-	case '(':
-		in.readRune()
-		return c
-
-	case ')':
-		in.readRune()
-		return c
+		in.endToken(tokenKind(c))
+		return
 
 	case '"', '`': // quoted string
 		quote := c
 		in.readRune()
 		for {
 			if in.eof() {
-				in.pos = sym.pos
+				in.pos = in.token.pos
 				in.Error("unexpected EOF in string")
 			}
 			if in.peekRune() == '\n' {
@@ -571,14 +577,14 @@
 			}
 			if c == '\\' && quote != '`' {
 				if in.eof() {
-					in.pos = sym.pos
+					in.pos = in.token.pos
 					in.Error("unexpected EOF in string")
 				}
 				in.readRune()
 			}
 		}
-		in.endToken(sym)
-		return _STRING
+		in.endToken(_STRING)
+		return
 	}
 
 	// Checked all punctuation. Must be identifier token.
@@ -596,13 +602,19 @@
 		}
 		in.readRune()
 	}
-	return _IDENT
+	in.endToken(_IDENT)
 }
 
 // isIdent reports whether c is an identifier rune.
-// We treat nearly all runes as identifier runes.
+// We treat most printable runes as identifier runes, except for a handful of
+// ASCII punctuation characters.
 func isIdent(c int) bool {
-	return c != 0 && !unicode.IsSpace(rune(c))
+	switch r := rune(c); r {
+	case ' ', '(', ')', '[', ']', '{', '}', ',':
+		return false
+	default:
+		return !unicode.IsSpace(r) && unicode.IsPrint(r)
+	}
 }
 
 // Comment assignment.
@@ -750,29 +762,29 @@
 
 func (in *input) parseFile() {
 	in.file = new(FileSyntax)
-	var sym symType
 	var cb *CommentBlock
 	for {
-		tok := in.lex(&sym)
-		switch tok {
+		switch in.peek() {
 		case '\n':
+			in.lex()
 			if cb != nil {
 				in.file.Stmt = append(in.file.Stmt, cb)
 				cb = nil
 			}
 		case _COMMENT:
+			tok := in.lex()
 			if cb == nil {
-				cb = &CommentBlock{Start: sym.pos}
+				cb = &CommentBlock{Start: tok.pos}
 			}
 			com := cb.Comment()
-			com.Before = append(com.Before, Comment{Start: sym.pos, Token: sym.text})
+			com.Before = append(com.Before, Comment{Start: tok.pos, Token: tok.text})
 		case _EOF:
 			if cb != nil {
 				in.file.Stmt = append(in.file.Stmt, cb)
 			}
 			return
 		default:
-			in.parseStmt(&sym)
+			in.parseStmt()
 			if cb != nil {
 				in.file.Stmt[len(in.file.Stmt)-1].Comment().Before = cb.Before
 				cb = nil
@@ -781,60 +793,88 @@
 	}
 }
 
-func (in *input) parseStmt(sym *symType) {
-	start := sym.pos
-	end := sym.endPos
-	token := []string{sym.text}
+func (in *input) parseStmt() {
+	tok := in.lex()
+	start := tok.pos
+	end := tok.endPos
+	tokens := []string{tok.text}
 	for {
-		tok := in.lex(sym)
-		switch tok {
-		case '\n', _EOF, _EOL:
+		tok := in.lex()
+		switch {
+		case tok.kind.isEOL():
 			in.file.Stmt = append(in.file.Stmt, &Line{
 				Start: start,
-				Token: token,
+				Token: tokens,
 				End:   end,
 			})
 			return
-		case '(':
-			in.file.Stmt = append(in.file.Stmt, in.parseLineBlock(start, token, sym))
-			return
+
+		case tok.kind == '(':
+			if next := in.peek(); next.isEOL() {
+				// Start of block: no more tokens on this line.
+				in.file.Stmt = append(in.file.Stmt, in.parseLineBlock(start, tokens, tok))
+				return
+			} else if next == ')' {
+				rparen := in.lex()
+				if in.peek().isEOL() {
+					// Empty block.
+					in.lex()
+					in.file.Stmt = append(in.file.Stmt, &LineBlock{
+						Start:  start,
+						Token:  tokens,
+						LParen: LParen{Pos: tok.pos},
+						RParen: RParen{Pos: rparen.pos},
+					})
+					return
+				}
+				// '( )' in the middle of the line, not a block.
+				tokens = append(tokens, tok.text, rparen.text)
+			} else {
+				// '(' in the middle of the line, not a block.
+				tokens = append(tokens, tok.text)
+			}
+
 		default:
-			token = append(token, sym.text)
-			end = sym.endPos
+			tokens = append(tokens, tok.text)
+			end = tok.endPos
 		}
 	}
 }
 
-func (in *input) parseLineBlock(start Position, token []string, sym *symType) *LineBlock {
+func (in *input) parseLineBlock(start Position, token []string, lparen token) *LineBlock {
 	x := &LineBlock{
 		Start:  start,
 		Token:  token,
-		LParen: LParen{Pos: sym.pos},
+		LParen: LParen{Pos: lparen.pos},
 	}
 	var comments []Comment
 	for {
-		tok := in.lex(sym)
-		switch tok {
-		case _EOL:
-			// ignore
+		switch in.peek() {
+		case _EOLCOMMENT:
+			// Suffix comment, will be attached later by assignComments.
+			in.lex()
 		case '\n':
+			// Blank line. Add an empty comment to preserve it.
+			in.lex()
 			if len(comments) == 0 && len(x.Line) > 0 || len(comments) > 0 && comments[len(comments)-1].Token != "" {
 				comments = append(comments, Comment{})
 			}
 		case _COMMENT:
-			comments = append(comments, Comment{Start: sym.pos, Token: sym.text})
+			tok := in.lex()
+			comments = append(comments, Comment{Start: tok.pos, Token: tok.text})
 		case _EOF:
 			in.Error(fmt.Sprintf("syntax error (unterminated block started at %s:%d:%d)", in.filename, x.Start.Line, x.Start.LineRune))
 		case ')':
+			rparen := in.lex()
 			x.RParen.Before = comments
-			x.RParen.Pos = sym.pos
-			tok = in.lex(sym)
-			if tok != '\n' && tok != _EOF && tok != _EOL {
+			x.RParen.Pos = rparen.pos
+			if !in.peek().isEOL() {
 				in.Error("syntax error (expected newline after closing paren)")
 			}
+			in.lex()
 			return x
 		default:
-			l := in.parseLine(sym)
+			l := in.parseLine()
 			x.Line = append(x.Line, l)
 			l.Comment().Before = comments
 			comments = nil
@@ -842,35 +882,29 @@
 	}
 }
 
-func (in *input) parseLine(sym *symType) *Line {
-	start := sym.pos
-	end := sym.endPos
-	token := []string{sym.text}
+func (in *input) parseLine() *Line {
+	tok := in.lex()
+	if tok.kind.isEOL() {
+		in.Error("internal parse error: parseLine at end of line")
+	}
+	start := tok.pos
+	end := tok.endPos
+	tokens := []string{tok.text}
 	for {
-		tok := in.lex(sym)
-		switch tok {
-		case '\n', _EOF, _EOL:
+		tok := in.lex()
+		if tok.kind.isEOL() {
 			return &Line{
 				Start:   start,
-				Token:   token,
+				Token:   tokens,
 				End:     end,
 				InBlock: true,
 			}
-		default:
-			token = append(token, sym.text)
-			end = sym.endPos
 		}
+		tokens = append(tokens, tok.text)
+		end = tok.endPos
 	}
 }
 
-const (
-	_EOF = -(1 + iota)
-	_EOL
-	_IDENT
-	_STRING
-	_COMMENT
-)
-
 var (
 	slashSlash = []byte("//")
 	moduleStr  = []byte("module")
diff --git a/modfile/read_test.go b/modfile/read_test.go
index 432b4af..f64d319 100644
--- a/modfile/read_test.go
+++ b/modfile/read_test.go
@@ -30,7 +30,12 @@
 		t.Fatal(err)
 	}
 	for _, out := range outs {
-		testPrint(t, out, out)
+		out := out
+		name := strings.TrimSuffix(filepath.Base(out), ".golden")
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			testPrint(t, out, out)
+		})
 	}
 }
 
@@ -66,6 +71,48 @@
 	}
 }
 
+// TestParsePunctuation verifies that certain ASCII punctuation characters
+// (brackets, commas) are lexed as separate tokens, even when they're
+// surrounded by identifier characters.
+func TestParsePunctuation(t *testing.T) {
+	for _, test := range []struct {
+		desc, src, want string
+	}{
+		{"paren", "require ()", "require ( )"},
+		{"brackets", "require []{},", "require [ ] { } ,"},
+		{"mix", "require a[b]c{d}e,", "require a [ b ] c { d } e ,"},
+		{"block_mix", "require (\n\ta[b]\n)", "require ( a [ b ] )"},
+		{"interval", "require [v1.0.0, v1.1.0)", "require [ v1.0.0 , v1.1.0 )"},
+	} {
+		t.Run(test.desc, func(t *testing.T) {
+			f, err := parse("go.mod", []byte(test.src))
+			if err != nil {
+				t.Fatalf("parsing %q: %v", test.src, err)
+			}
+			var tokens []string
+			for _, stmt := range f.Stmt {
+				switch stmt := stmt.(type) {
+				case *Line:
+					tokens = append(tokens, stmt.Token...)
+				case *LineBlock:
+					tokens = append(tokens, stmt.Token...)
+					tokens = append(tokens, "(")
+					for _, line := range stmt.Line {
+						tokens = append(tokens, line.Token...)
+					}
+					tokens = append(tokens, ")")
+				default:
+					t.Fatalf("parsing %q: unexpected statement of type %T", test.src, stmt)
+				}
+			}
+			got := strings.Join(tokens, " ")
+			if got != test.want {
+				t.Errorf("parsing %q: got %q, want %q", test.src, got, test.want)
+			}
+		})
+	}
+}
+
 func TestParseLax(t *testing.T) {
 	badFile := []byte(`module m
 		surprise attack
@@ -103,77 +150,76 @@
 		t.Fatal(err)
 	}
 	for _, out := range outs {
-		data, err := ioutil.ReadFile(out)
-		if err != nil {
-			t.Error(err)
-			continue
-		}
-
-		base := "testdata/" + filepath.Base(out)
-		f, err := parse(base, data)
-		if err != nil {
-			t.Errorf("parsing original: %v", err)
-			continue
-		}
-
-		ndata := Format(f)
-		f2, err := parse(base, ndata)
-		if err != nil {
-			t.Errorf("parsing reformatted: %v", err)
-			continue
-		}
-
-		eq := eqchecker{file: base}
-		if err := eq.check(f, f2); err != nil {
-			t.Errorf("not equal (parse/Format/parse): %v", err)
-		}
-
-		pf1, err := Parse(base, data, nil)
-		if err != nil {
-			switch base {
-			case "testdata/replace2.in", "testdata/gopkg.in.golden":
-				t.Errorf("should parse %v: %v", base, err)
-			}
-		}
-		if err == nil {
-			pf2, err := Parse(base, ndata, nil)
+		out := out
+		name := filepath.Base(out)
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			data, err := ioutil.ReadFile(out)
 			if err != nil {
-				t.Errorf("Parsing reformatted: %v", err)
-				continue
+				t.Fatal(err)
 			}
+
+			base := "testdata/" + filepath.Base(out)
+			f, err := parse(base, data)
+			if err != nil {
+				t.Fatalf("parsing original: %v", err)
+			}
+
+			ndata := Format(f)
+			f2, err := parse(base, ndata)
+			if err != nil {
+				t.Fatalf("parsing reformatted: %v", err)
+			}
+
 			eq := eqchecker{file: base}
-			if err := eq.check(pf1, pf2); err != nil {
-				t.Errorf("not equal (parse/Format/Parse): %v", err)
+			if err := eq.check(f, f2); err != nil {
+				t.Errorf("not equal (parse/Format/parse): %v", err)
 			}
 
-			ndata2, err := pf1.Format()
+			pf1, err := Parse(base, data, nil)
 			if err != nil {
-				t.Errorf("reformat: %v", err)
+				switch base {
+				case "testdata/replace2.in", "testdata/gopkg.in.golden":
+					t.Errorf("should parse %v: %v", base, err)
+				}
 			}
-			pf3, err := Parse(base, ndata2, nil)
-			if err != nil {
-				t.Errorf("Parsing reformatted2: %v", err)
-				continue
-			}
-			eq = eqchecker{file: base}
-			if err := eq.check(pf1, pf3); err != nil {
-				t.Errorf("not equal (Parse/Format/Parse): %v", err)
-			}
-			ndata = ndata2
-		}
+			if err == nil {
+				pf2, err := Parse(base, ndata, nil)
+				if err != nil {
+					t.Fatalf("Parsing reformatted: %v", err)
+				}
+				eq := eqchecker{file: base}
+				if err := eq.check(pf1, pf2); err != nil {
+					t.Errorf("not equal (parse/Format/Parse): %v", err)
+				}
 
-		if strings.HasSuffix(out, ".in") {
-			golden, err := ioutil.ReadFile(strings.TrimSuffix(out, ".in") + ".golden")
-			if err != nil {
-				t.Error(err)
-				continue
+				ndata2, err := pf1.Format()
+				if err != nil {
+					t.Errorf("reformat: %v", err)
+				}
+				pf3, err := Parse(base, ndata2, nil)
+				if err != nil {
+					t.Fatalf("Parsing reformatted2: %v", err)
+				}
+				eq = eqchecker{file: base}
+				if err := eq.check(pf1, pf3); err != nil {
+					t.Errorf("not equal (Parse/Format/Parse): %v", err)
+				}
+				ndata = ndata2
 			}
-			if !bytes.Equal(ndata, golden) {
-				t.Errorf("formatted %s incorrectly: diff shows -golden, +ours", base)
-				tdiff(t, string(golden), string(ndata))
-				return
+
+			if strings.HasSuffix(out, ".in") {
+				golden, err := ioutil.ReadFile(strings.TrimSuffix(out, ".in") + ".golden")
+				if err != nil {
+					t.Fatal(err)
+				}
+				if !bytes.Equal(ndata, golden) {
+					t.Errorf("formatted %s incorrectly: diff shows -golden, +ours", base)
+					tdiff(t, string(golden), string(ndata))
+					return
+				}
 			}
-		}
+		})
 	}
 }
 
diff --git a/modfile/rule.go b/modfile/rule.go
index 462cb4e..91ca682 100644
--- a/modfile/rule.go
+++ b/modfile/rule.go
@@ -417,8 +417,19 @@
 // a single token in a go.mod line.
 func MustQuote(s string) bool {
 	for _, r := range s {
-		if !unicode.IsPrint(r) || r == ' ' || r == '"' || r == '\'' || r == '`' {
+		switch r {
+		case ' ', '"', '\'', '`':
 			return true
+
+		case '(', ')', '[', ']', '{', '}', ',':
+			if len(s) > 1 {
+				return true
+			}
+
+		default:
+			if !unicode.IsPrint(r) {
+				return true
+			}
 		}
 	}
 	return s == "" || strings.Contains(s, "//") || strings.Contains(s, "/*")
diff --git a/modfile/testdata/block.golden b/modfile/testdata/block.golden
index 4aa2d63..430c42e 100644
--- a/modfile/testdata/block.golden
+++ b/modfile/testdata/block.golden
@@ -5,7 +5,8 @@
 block ( // block-eol
 	// x-before-line
 
-	"x" ( y // x-eol
+	"x" (y // x-eol
+	"x") y // y-eol
 	"x1"
 	"x2"
 	// line
@@ -20,10 +21,16 @@
 	"z" // z-eol
 ) // block-eol2
 
-block2 (
-	x
-	y
-	z
+block1 (
 )
 
+block2 (x y z)
+
+block3 "w" (
+) // empty block
+
+block4 "x" () "y" // not a block
+
+block5 ("z" // also not a block
+
 // eof
diff --git a/modfile/testdata/block.in b/modfile/testdata/block.in
index 1dfae65..a19990e 100644
--- a/modfile/testdata/block.in
+++ b/modfile/testdata/block.in
@@ -6,6 +6,7 @@
 	// x-before-line
 	
 	"x" ( y // x-eol
+	"x" ) y // y-eol
 	"x1"
 	"x2"
 	// line
@@ -21,9 +22,12 @@
 ) // block-eol2
 
 
-block2 (x
-	y
-	z
-)
+block1()
+
+block2 (x y z)
+
+block3 "w" ( ) // empty block
+block4 "x" ( ) "y" // not a block
+block5 ( "z" // also not a block
 
 // eof
diff --git a/modfile/testdata/replace.golden b/modfile/testdata/replace.golden
index 5d6abcf..2e56d92 100644
--- a/modfile/testdata/replace.golden
+++ b/modfile/testdata/replace.golden
@@ -3,3 +3,10 @@
 replace xyz v1.2.3 => /tmp/z
 
 replace xyz v1.3.4 => my/xyz v1.3.4-me
+
+replace (
+	w v1.0.0 => "./a,"
+	w v1.0.1 => "./a()"
+	w v1.0.2 => "./a[]"
+	w v1.0.3 => "./a{}"
+)
diff --git a/modfile/testdata/replace.in b/modfile/testdata/replace.in
index 6852499..f5bef26 100644
--- a/modfile/testdata/replace.in
+++ b/modfile/testdata/replace.in
@@ -3,3 +3,10 @@
 replace "xyz" v1.2.3 => "/tmp/z"
 
 replace "xyz" v1.3.4 => "my/xyz" v1.3.4-me
+
+replace (
+	"w" v1.0.0 => "./a,"
+	"w" v1.0.1 => "./a()"
+	"w" v1.0.2 => "./a[]"
+	"w" v1.0.3 => "./a{}"
+)