modfile: support retract directive and version intervals

This CL adds support for parsing and programmatically adding and
removing a new directive, "retract", as described in golang/go#24031.

The "retract" directive comes in two forms:

    retract v1.0.0           // single version
    retract [v1.1.0, v1.2.0] // closed interval

Updates golang/go#24031

Change-Id: I1236c7d89e7674abf694e49e9b4869b14a59fac0
Reviewed-on: https://go-review.googlesource.com/c/mod/+/228039
Run-TryBot: Jay Conrod <jayconrod@google.com>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Michael Matloob <matloob@golang.org>
diff --git a/modfile/rule.go b/modfile/rule.go
index 91ca682..83398dd 100644
--- a/modfile/rule.go
+++ b/modfile/rule.go
@@ -30,6 +30,7 @@
 
 	"golang.org/x/mod/internal/lazyregexp"
 	"golang.org/x/mod/module"
+	"golang.org/x/mod/semver"
 )
 
 // A File is the parsed, interpreted form of a go.mod file.
@@ -39,6 +40,7 @@
 	Require []*Require
 	Exclude []*Exclude
 	Replace []*Replace
+	Retract []*Retract
 
 	Syntax *FileSyntax
 }
@@ -75,6 +77,21 @@
 	Syntax *Line
 }
 
+// A Retract is a single retract statement.
+type Retract struct {
+	VersionInterval
+	Rationale string
+	Syntax    *Line
+}
+
+// A VersionInterval represents a range of versions with upper and lower bounds.
+// Intervals are closed: both bounds are included. When Low is equal to High,
+// the interval may refer to a single version ('v1.2.3') or an interval
+// ('[v1.2.3, v1.2.3]'); both have the same representation.
+type VersionInterval struct {
+	Low, High string
+}
+
 func (f *File) AddModuleStmt(path string) error {
 	if f.Syntax == nil {
 		f.Syntax = new(FileSyntax)
@@ -138,7 +155,7 @@
 	for _, x := range fs.Stmt {
 		switch x := x.(type) {
 		case *Line:
-			f.add(&errs, x, x.Token[0], x.Token[1:], fix, strict)
+			f.add(&errs, nil, x, x.Token[0], x.Token[1:], fix, strict)
 
 		case *LineBlock:
 			if len(x.Token) > 1 {
@@ -161,9 +178,9 @@
 					})
 				}
 				continue
-			case "module", "require", "exclude", "replace":
+			case "module", "require", "exclude", "replace", "retract":
 				for _, l := range x.Line {
-					f.add(&errs, l, x.Token[0], l.Token, fix, strict)
+					f.add(&errs, x, l, x.Token[0], l.Token, fix, strict)
 				}
 			}
 		}
@@ -177,7 +194,7 @@
 
 var GoVersionRE = lazyregexp.New(`^([1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
 
-func (f *File) add(errs *ErrorList, line *Line, verb string, args []string, fix VersionFixer, strict bool) {
+func (f *File) add(errs *ErrorList, block *LineBlock, line *Line, verb string, args []string, fix VersionFixer, strict bool) {
 	// If strict is false, this module is a dependency.
 	// We ignore all unknown directives as well as main-module-only
 	// directives like replace and exclude. It will work better for
@@ -186,7 +203,7 @@
 	// and simply ignore those statements.
 	if !strict {
 		switch verb {
-		case "module", "require", "go":
+		case "go", "module", "retract", "require":
 			// want these even for dependency go.mods
 		default:
 			return
@@ -232,6 +249,7 @@
 
 		f.Go = &Go{Syntax: line}
 		f.Go.Version = args[0]
+
 	case "module":
 		if f.Module != nil {
 			errorf("repeated module statement")
@@ -248,6 +266,7 @@
 			return
 		}
 		f.Module.Mod = module.Version{Path: s}
+
 	case "require", "exclude":
 		if len(args) != 2 {
 			errorf("usage: %s module/path v1.2.3", verb)
@@ -284,6 +303,7 @@
 				Syntax: line,
 			})
 		}
+
 	case "replace":
 		arrow := 2
 		if len(args) >= 2 && args[1] == "=>" {
@@ -347,6 +367,33 @@
 			New:    module.Version{Path: ns, Version: nv},
 			Syntax: line,
 		})
+
+	case "retract":
+		rationale := parseRetractRationale(block, line)
+		vi, err := parseVersionInterval(verb, &args, fix)
+		if err != nil {
+			if strict {
+				wrapError(err)
+				return
+			} else {
+				// Only report errors parsing intervals in the main module. We may
+				// support additional syntax in the future, such as open and half-open
+				// intervals. Those can't be supported now, because they break the
+				// go.mod parser, even in lax mode.
+				return
+			}
+		}
+		if len(args) > 0 && strict {
+			// In the future, there may be additional information after the version.
+			errorf("unexpected token after version: %q", args[0])
+			return
+		}
+		retract := &Retract{
+			VersionInterval: vi,
+			Rationale:       rationale,
+			Syntax:          line,
+		}
+		f.Retract = append(f.Retract, retract)
 	}
 }
 
@@ -444,6 +491,53 @@
 	return s
 }
 
+func parseVersionInterval(verb string, args *[]string, fix VersionFixer) (VersionInterval, error) {
+	toks := *args
+	if len(toks) == 0 || toks[0] == "(" {
+		return VersionInterval{}, fmt.Errorf("expected '[' or version")
+	}
+	if toks[0] != "[" {
+		v, err := parseVersion(verb, "", &toks[0], fix)
+		if err != nil {
+			return VersionInterval{}, err
+		}
+		*args = toks[1:]
+		return VersionInterval{Low: v, High: v}, nil
+	}
+	toks = toks[1:]
+
+	if len(toks) == 0 {
+		return VersionInterval{}, fmt.Errorf("expected version after '['")
+	}
+	low, err := parseVersion(verb, "", &toks[0], fix)
+	if err != nil {
+		return VersionInterval{}, err
+	}
+	toks = toks[1:]
+
+	if len(toks) == 0 || toks[0] != "," {
+		return VersionInterval{}, fmt.Errorf("expected ',' after version")
+	}
+	toks = toks[1:]
+
+	if len(toks) == 0 {
+		return VersionInterval{}, fmt.Errorf("expected version after ','")
+	}
+	high, err := parseVersion(verb, "", &toks[0], fix)
+	if err != nil {
+		return VersionInterval{}, err
+	}
+	toks = toks[1:]
+
+	if len(toks) == 0 || toks[0] != "]" {
+		return VersionInterval{}, fmt.Errorf("expected ']' after version")
+	}
+	toks = toks[1:]
+
+	*args = toks
+	return VersionInterval{Low: low, High: high}, nil
+}
+
 func parseString(s *string) (string, error) {
 	t := *s
 	if strings.HasPrefix(t, `"`) {
@@ -461,6 +555,27 @@
 	return t, nil
 }
 
+// parseRetractRationale extracts the rationale for a retract directive from the
+// surrounding comments. If the line does not have comments and is part of a
+// block that does have comments, the block's comments are used.
+func parseRetractRationale(block *LineBlock, line *Line) string {
+	comments := line.Comment()
+	if block != nil && len(comments.Before) == 0 && len(comments.Suffix) == 0 {
+		comments = block.Comment()
+	}
+	groups := [][]Comment{comments.Before, comments.Suffix}
+	var lines []string
+	for _, g := range groups {
+		for _, c := range g {
+			if !strings.HasPrefix(c.Token, "//") {
+				continue // blank line
+			}
+			lines = append(lines, strings.TrimSpace(strings.TrimPrefix(c.Token, "//")))
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
 type ErrorList []Error
 
 func (e ErrorList) Error() string {
@@ -494,6 +609,8 @@
 	var directive string
 	if e.ModPath != "" {
 		directive = fmt.Sprintf("%s %s: ", e.Verb, e.ModPath)
+	} else if e.Verb != "" {
+		directive = fmt.Sprintf("%s: ", e.Verb)
 	}
 
 	return pos + directive + e.Err.Error()
@@ -585,6 +702,15 @@
 	}
 	f.Replace = f.Replace[:w]
 
+	w = 0
+	for _, r := range f.Retract {
+		if r.Low != "" || r.High != "" {
+			f.Retract[w] = r
+			w++
+		}
+	}
+	f.Retract = f.Retract[:w]
+
 	f.Syntax.Cleanup()
 }
 
@@ -778,6 +904,34 @@
 	return nil
 }
 
+func (f *File) AddRetract(vi VersionInterval, rationale string) error {
+	r := &Retract{
+		VersionInterval: vi,
+	}
+	if vi.Low == vi.High {
+		r.Syntax = f.Syntax.addLine(nil, "retract", AutoQuote(vi.Low))
+	} else {
+		r.Syntax = f.Syntax.addLine(nil, "retract", "[", AutoQuote(vi.Low), ",", AutoQuote(vi.High), "]")
+	}
+	if rationale != "" {
+		for _, line := range strings.Split(rationale, "\n") {
+			com := Comment{Token: "// " + line}
+			r.Syntax.Comment().Before = append(r.Syntax.Comment().Before, com)
+		}
+	}
+	return nil
+}
+
+func (f *File) DropRetract(vi VersionInterval) error {
+	for _, r := range f.Retract {
+		if r.VersionInterval == vi {
+			f.Syntax.removeLine(r.Syntax)
+			*r = Retract{}
+		}
+	}
+	return nil
+}
+
 func (f *File) SortBlocks() {
 	f.removeDups() // otherwise sorting is unsafe
 
@@ -786,28 +940,38 @@
 		if !ok {
 			continue
 		}
-		sort.Slice(block.Line, func(i, j int) bool {
-			li := block.Line[i]
-			lj := block.Line[j]
-			for k := 0; k < len(li.Token) && k < len(lj.Token); k++ {
-				if li.Token[k] != lj.Token[k] {
-					return li.Token[k] < lj.Token[k]
-				}
-			}
-			return len(li.Token) < len(lj.Token)
+		less := lineLess
+		if block.Token[0] == "retract" {
+			less = lineRetractLess
+		}
+		sort.SliceStable(block.Line, func(i, j int) bool {
+			return less(block.Line[i], block.Line[j])
 		})
 	}
 }
 
+// removeDups removes duplicate exclude and replace directives.
+//
+// Earlier exclude directives take priority.
+//
+// Later replace directives take priority.
+//
+// require directives are not de-duplicated. That's left up to higher-level
+// logic (MVS).
+//
+// retract directives are not de-duplicated since comments are
+// meaningful, and versions may be retracted multiple times.
 func (f *File) removeDups() {
-	have := make(map[module.Version]bool)
 	kill := make(map[*Line]bool)
+
+	// Remove duplicate excludes.
+	haveExclude := make(map[module.Version]bool)
 	for _, x := range f.Exclude {
-		if have[x.Mod] {
+		if haveExclude[x.Mod] {
 			kill[x.Syntax] = true
 			continue
 		}
-		have[x.Mod] = true
+		haveExclude[x.Mod] = true
 	}
 	var excl []*Exclude
 	for _, x := range f.Exclude {
@@ -817,15 +981,16 @@
 	}
 	f.Exclude = excl
 
-	have = make(map[module.Version]bool)
+	// Remove duplicate replacements.
 	// Later replacements take priority over earlier ones.
+	haveReplace := make(map[module.Version]bool)
 	for i := len(f.Replace) - 1; i >= 0; i-- {
 		x := f.Replace[i]
-		if have[x.Old] {
+		if haveReplace[x.Old] {
 			kill[x.Syntax] = true
 			continue
 		}
-		have[x.Old] = true
+		haveReplace[x.Old] = true
 	}
 	var repl []*Replace
 	for _, x := range f.Replace {
@@ -835,6 +1000,9 @@
 	}
 	f.Replace = repl
 
+	// Duplicate require and retract directives are not removed.
+
+	// Drop killed statements from the syntax tree.
 	var stmts []Expr
 	for _, stmt := range f.Syntax.Stmt {
 		switch stmt := stmt.(type) {
@@ -858,3 +1026,38 @@
 	}
 	f.Syntax.Stmt = stmts
 }
+
+// lineLess returns whether li should be sorted before lj. It sorts
+// lexicographically without assigning any special meaning to tokens.
+func lineLess(li, lj *Line) bool {
+	for k := 0; k < len(li.Token) && k < len(lj.Token); k++ {
+		if li.Token[k] != lj.Token[k] {
+			return li.Token[k] < lj.Token[k]
+		}
+	}
+	return len(li.Token) < len(lj.Token)
+}
+
+// lineRetractLess returns whether li should be sorted before lj for lines in
+// a "retract" block. It treats each line as a version interval. Single versions
+// are compared as if they were intervals with the same low and high version.
+// Intervals are sorted in descending order, first by low version, then by
+// high version, using semver.Compare.
+func lineRetractLess(li, lj *Line) bool {
+	interval := func(l *Line) VersionInterval {
+		if len(l.Token) == 1 {
+			return VersionInterval{Low: l.Token[0], High: l.Token[0]}
+		} else if len(l.Token) == 5 && l.Token[0] == "[" && l.Token[2] == "," && l.Token[4] == "]" {
+			return VersionInterval{Low: l.Token[1], High: l.Token[3]}
+		} else {
+			// Line in unknown format. Treat as an invalid version.
+			return VersionInterval{}
+		}
+	}
+	vii := interval(li)
+	vij := interval(lj)
+	if cmp := semver.Compare(vii.Low, vij.Low); cmp != 0 {
+		return cmp > 0
+	}
+	return semver.Compare(vii.High, vij.High) > 0
+}
diff --git a/modfile/rule_test.go b/modfile/rule_test.go
index c2c28f9..fbf144d 100644
--- a/modfile/rule_test.go
+++ b/modfile/rule_test.go
@@ -6,19 +6,20 @@
 
 import (
 	"bytes"
-	"fmt"
 	"testing"
 
 	"golang.org/x/mod/module"
 )
 
 var addRequireTests = []struct {
+	desc string
 	in   string
 	path string
 	vers string
 	out  string
 }{
 	{
+		`existing`,
 		`
 		module m
 		require x.y/z v1.2.3
@@ -30,6 +31,7 @@
 		`,
 	},
 	{
+		`new`,
 		`
 		module m
 		require x.y/z v1.2.3
@@ -44,6 +46,7 @@
 		`,
 	},
 	{
+		`new2`,
 		`
 		module m
 		require x.y/z v1.2.3
@@ -62,6 +65,7 @@
 }
 
 var setRequireTests = []struct {
+	desc string
 	in   string
 	mods []struct {
 		path     string
@@ -71,6 +75,7 @@
 	out string
 }{
 	{
+		`existing`,
 		`module m
 		require (
 			x.y/b v1.2.3
@@ -97,6 +102,7 @@
 		`,
 	},
 	{
+		`existing_indirect`,
 		`module m
 		require (
 			x.y/a v1.2.3
@@ -136,18 +142,23 @@
 }
 
 var addGoTests = []struct {
+	desc    string
 	in      string
 	version string
 	out     string
 }{
-	{`module m
+	{
+		`module_only`,
+		`module m
 		`,
 		`1.14`,
 		`module m
 		go 1.14
 		`,
 	},
-	{`module m
+	{
+		`module_before_require`,
+		`module m
 		require x.y/a v1.2.3
 		`,
 		`1.14`,
@@ -157,6 +168,7 @@
 		`,
 	},
 	{
+		`require_before_module`,
 		`require x.y/a v1.2.3
 		module example.com/inverted
 		`,
@@ -167,6 +179,7 @@
 		`,
 	},
 	{
+		`require_only`,
 		`require x.y/a v1.2.3
 		`,
 		`1.14`,
@@ -176,51 +189,398 @@
 	},
 }
 
-func TestAddRequire(t *testing.T) {
-	for i, tt := range addRequireTests {
-		t.Run(fmt.Sprintf("#%d", i), func(t *testing.T) {
-			f, err := Parse("in", []byte(tt.in), nil)
-			if err != nil {
-				t.Fatal(err)
-			}
-			g, err := Parse("out", []byte(tt.out), nil)
-			if err != nil {
-				t.Fatal(err)
-			}
-			golden, err := g.Format()
-			if err != nil {
-				t.Fatal(err)
-			}
+var addRetractTests = []struct {
+	desc      string
+	in        string
+	low       string
+	high      string
+	rationale string
+	out       string
+}{
+	{
+		`new_singleton`,
+		`module m
+		`,
+		`v1.2.3`,
+		`v1.2.3`,
+		``,
+		`module m
+		retract v1.2.3
+		`,
+	},
+	{
+		`new_interval`,
+		`module m
+		`,
+		`v1.0.0`,
+		`v1.1.0`,
+		``,
+		`module m
+		retract [v1.0.0, v1.1.0]`,
+	},
+	{
+		`duplicate_with_rationale`,
+		`module m
+		retract v1.2.3
+		`,
+		`v1.2.3`,
+		`v1.2.3`,
+		`bad`,
+		`module m
+		retract (
+			v1.2.3
+			// bad
+			v1.2.3
+		)
+		`,
+	},
+	{
+		`duplicate_multiline_rationale`,
+		`module m
+		retract [v1.2.3, v1.2.3]
+		`,
+		`v1.2.3`,
+		`v1.2.3`,
+		`multi
+line`,
+		`module m
+		retract	(
+			[v1.2.3, v1.2.3]
+			// multi
+			// line
+			v1.2.3
+		)
+		`,
+	},
+	{
+		`duplicate_interval`,
+		`module m
+		retract [v1.0.0, v1.1.0]
+		`,
+		`v1.0.0`,
+		`v1.1.0`,
+		``,
+		`module m
+		retract (
+			[v1.0.0, v1.1.0]
+			[v1.0.0, v1.1.0]
+		)
+		`,
+	},
+	{
+		`duplicate_singleton`,
+		`module m
+		retract v1.2.3
+		`,
+		`v1.2.3`,
+		`v1.2.3`,
+		``,
+		`module m
+		retract	(
+			v1.2.3
+			v1.2.3
+		)
+		`,
+	},
+}
 
-			if err := f.AddRequire(tt.path, tt.vers); err != nil {
-				t.Fatal(err)
-			}
-			out, err := f.Format()
-			if err != nil {
-				t.Fatal(err)
-			}
-			if !bytes.Equal(out, golden) {
-				t.Errorf("have:\n%s\nwant:\n%s", out, golden)
-			}
+var dropRetractTests = []struct {
+	desc string
+	in   string
+	low  string
+	high string
+	out  string
+}{
+	{
+		`singleton_no_match`,
+		`module m
+		retract v1.2.3
+		`,
+		`v1.0.0`,
+		`v1.0.0`,
+		`module m
+		retract v1.2.3
+		`,
+	},
+	{
+		`singleton_match_one`,
+		`module m
+		retract v1.2.2
+		retract v1.2.3
+		retract v1.2.4
+		`,
+		`v1.2.3`,
+		`v1.2.3`,
+		`module m
+		retract v1.2.2
+		retract v1.2.4
+		`,
+	},
+	{
+		`singleton_match_all`,
+		`module m
+		retract v1.2.3 // first
+		retract v1.2.3 // second
+		`,
+		`v1.2.3`,
+		`v1.2.3`,
+		`module m
+		`,
+	},
+	{
+		`interval_match`,
+		`module m
+		retract [v1.2.3, v1.2.3]
+		`,
+		`v1.2.3`,
+		`v1.2.3`,
+		`module m
+		`,
+	},
+	{
+		`interval_superset_no_match`,
+		`module m
+		retract [v1.0.0, v1.1.0]
+		`,
+		`v1.0.0`,
+		`v1.2.0`,
+		`module m
+		retract [v1.0.0, v1.1.0]
+		`,
+	},
+	{
+		`singleton_match_middle`,
+		`module m
+		retract v1.2.3
+		`,
+		`v1.2.3`,
+		`v1.2.3`,
+		`module m
+		`,
+	},
+	{
+		`interval_match_middle_block`,
+		`module m
+		retract (
+			v1.0.0
+			[v1.1.0, v1.2.0]
+			v1.3.0
+		)
+		`,
+		`v1.1.0`,
+		`v1.2.0`,
+		`module m
+		retract (
+			v1.0.0
+			v1.3.0
+		)
+		`,
+	},
+	{
+		`interval_match_all`,
+		`module m
+		retract [v1.0.0, v1.1.0]
+		retract [v1.0.0, v1.1.0]
+		`,
+		`v1.0.0`,
+		`v1.1.0`,
+		`module m
+		`,
+	},
+}
+
+var retractRationaleTests = []struct {
+	desc, in, want string
+}{
+	{
+		`no_comment`,
+		`module m
+		retract v1.0.0`,
+		``,
+	},
+	{
+		`prefix_one`,
+		`module m
+		//   prefix
+		retract v1.0.0 
+		`,
+		`prefix`,
+	},
+	{
+		`prefix_multiline`,
+		`module m
+		//  one
+		//
+		//     two
+		//
+		// three  
+		retract v1.0.0`,
+		`one
+
+two
+
+three`,
+	},
+	{
+		`suffix`,
+		`module m
+		retract v1.0.0 // suffix
+		`,
+		`suffix`,
+	},
+	{
+		`prefix_suffix_after`,
+		`module m
+		// prefix
+		retract v1.0.0 // suffix
+		`,
+		`prefix
+suffix`,
+	},
+	{
+		`block_only`,
+		`// block
+		retract (
+			v1.0.0
+		)
+		`,
+		`block`,
+	},
+	{
+		`block_and_line`,
+		`// block
+		retract (
+			// line
+			v1.0.0
+		)
+		`,
+		`line`,
+	},
+}
+
+var sortBlocksTests = []struct {
+	desc, in, out string
+	strict        bool
+}{
+	{
+		`exclude_duplicates_removed`,
+		`module m
+		exclude x.y/z v1.0.0 // a
+		exclude x.y/z v1.0.0 // b
+		exclude (
+			x.y/w v1.1.0
+			x.y/z v1.0.0 // c
+		)
+		`,
+		`module m
+		exclude x.y/z v1.0.0 // a
+		exclude (
+			x.y/w v1.1.0
+		)`,
+		true,
+	},
+	{
+		`replace_duplicates_removed`,
+		`module m
+		replace x.y/z v1.0.0 => ./a
+		replace x.y/z v1.1.0 => ./b
+		replace (
+			x.y/z v1.0.0 => ./c
+		)
+		`,
+		`module m
+		replace x.y/z v1.1.0 => ./b
+		replace (
+			x.y/z v1.0.0 => ./c
+		)
+		`,
+		true,
+	},
+	{
+		`retract_duplicates_not_removed`,
+		`module m
+		// block
+		retract (
+			v1.0.0 // one
+			v1.0.0 // two
+		)`,
+		`module m
+		// block
+		retract (
+			v1.0.0 // one
+			v1.0.0 // two
+		)`,
+		true,
+	},
+	// Tests below this point just check sort order.
+	// Non-retract blocks are sorted lexicographically in ascending order.
+	// retract blocks are sorted using semver in descending order.
+	{
+		`sort_lexicographically`,
+		`module m
+		sort (
+			aa
+			cc
+			bb
+			zz
+			v1.2.0
+			v1.11.0
+		)`,
+		`module m
+		sort (
+			aa
+			bb
+			cc
+			v1.11.0
+			v1.2.0
+			zz
+		)
+		`,
+		false,
+	},
+	{
+		`sort_retract`,
+		`module m
+		retract (
+			[v1.2.0, v1.3.0]
+			[v1.1.0, v1.3.0]
+			[v1.1.0, v1.2.0]
+			v1.0.0
+			v1.1.0
+			v1.2.0
+			v1.3.0
+			v1.4.0
+		)
+		`,
+		`module m
+		retract (
+			v1.4.0
+			v1.3.0
+			[v1.2.0, v1.3.0]
+			v1.2.0
+			[v1.1.0, v1.3.0]
+			[v1.1.0, v1.2.0]
+			v1.1.0
+			v1.0.0
+		)
+		`,
+		false,
+	},
+}
+
+func TestAddRequire(t *testing.T) {
+	for _, tt := range addRequireTests {
+		t.Run(tt.desc, func(t *testing.T) {
+			testEdit(t, tt.in, tt.out, true, func(f *File) error {
+				return f.AddRequire(tt.path, tt.vers)
+			})
 		})
 	}
 }
 
 func TestSetRequire(t *testing.T) {
-	for i, tt := range setRequireTests {
-		t.Run(fmt.Sprintf("#%d", i), func(t *testing.T) {
-			f, err := Parse("in", []byte(tt.in), nil)
-			if err != nil {
-				t.Fatal(err)
-			}
-			g, err := Parse("out", []byte(tt.out), nil)
-			if err != nil {
-				t.Fatal(err)
-			}
-			golden, err := g.Format()
-			if err != nil {
-				t.Fatal(err)
-			}
+	for _, tt := range setRequireTests {
+		t.Run(tt.desc, func(t *testing.T) {
 			var mods []*Require
 			for _, mod := range tt.mods {
 				mods = append(mods, &Require{
@@ -232,14 +592,10 @@
 				})
 			}
 
-			f.SetRequire(mods)
-			out, err := f.Format()
-			if err != nil {
-				t.Fatal(err)
-			}
-			if !bytes.Equal(out, golden) {
-				t.Errorf("have:\n%s\nwant:\n%s", out, golden)
-			}
+			f := testEdit(t, tt.in, tt.out, true, func(f *File) error {
+				f.SetRequire(mods)
+				return nil
+			})
 
 			f.Cleanup()
 			if len(f.Require) != len(mods) {
@@ -250,31 +606,96 @@
 }
 
 func TestAddGo(t *testing.T) {
-	for i, tt := range addGoTests {
-		t.Run(fmt.Sprintf("#%d", i), func(t *testing.T) {
+	for _, tt := range addGoTests {
+		t.Run(tt.desc, func(t *testing.T) {
+			testEdit(t, tt.in, tt.out, true, func(f *File) error {
+				return f.AddGoStmt(tt.version)
+			})
+		})
+	}
+}
+
+func TestAddRetract(t *testing.T) {
+	for _, tt := range addRetractTests {
+		t.Run(tt.desc, func(t *testing.T) {
+			testEdit(t, tt.in, tt.out, true, func(f *File) error {
+				return f.AddRetract(VersionInterval{Low: tt.low, High: tt.high}, tt.rationale)
+			})
+		})
+	}
+}
+
+func TestDropRetract(t *testing.T) {
+	for _, tt := range dropRetractTests {
+		t.Run(tt.desc, func(t *testing.T) {
+			testEdit(t, tt.in, tt.out, true, func(f *File) error {
+				if err := f.DropRetract(VersionInterval{Low: tt.low, High: tt.high}); err != nil {
+					return err
+				}
+				f.Cleanup()
+				return nil
+			})
+		})
+	}
+}
+
+func TestRetractRationale(t *testing.T) {
+	for _, tt := range retractRationaleTests {
+		t.Run(tt.desc, func(t *testing.T) {
 			f, err := Parse("in", []byte(tt.in), nil)
 			if err != nil {
 				t.Fatal(err)
 			}
-			g, err := Parse("out", []byte(tt.out), nil)
-			if err != nil {
-				t.Fatal(err)
+			if len(f.Retract) != 1 {
+				t.Fatalf("got %d retract directives; want 1", len(f.Retract))
 			}
-			golden, err := g.Format()
-			if err != nil {
-				t.Fatal(err)
-			}
-
-			if err := f.AddGoStmt(tt.version); err != nil {
-				t.Fatal(err)
-			}
-			out, err := f.Format()
-			if err != nil {
-				t.Fatal(err)
-			}
-			if !bytes.Equal(out, golden) {
-				t.Errorf("have:\n%s\nwant:\n%s", out, golden)
+			if got := f.Retract[0].Rationale; got != tt.want {
+				t.Errorf("got %q; want %q", got, tt.want)
 			}
 		})
 	}
 }
+
+func TestSortBlocks(t *testing.T) {
+	for _, tt := range sortBlocksTests {
+		t.Run(tt.desc, func(t *testing.T) {
+			testEdit(t, tt.in, tt.out, tt.strict, func(f *File) error {
+				f.SortBlocks()
+				return nil
+			})
+		})
+	}
+}
+
+func testEdit(t *testing.T, in, want string, strict bool, transform func(f *File) error) *File {
+	t.Helper()
+	parse := Parse
+	if !strict {
+		parse = ParseLax
+	}
+	f, err := parse("in", []byte(in), nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	g, err := parse("out", []byte(want), nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	golden, err := g.Format()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if err := transform(f); err != nil {
+		t.Fatal(err)
+	}
+	out, err := f.Format()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !bytes.Equal(out, golden) {
+		t.Errorf("have:\n%s\nwant:\n%s", out, golden)
+	}
+
+	return f
+}
diff --git a/modfile/testdata/retract.golden b/modfile/testdata/retract.golden
new file mode 100644
index 0000000..f5d709e
--- /dev/null
+++ b/modfile/testdata/retract.golden
@@ -0,0 +1,11 @@
+module abc
+
+retract v1.2.3
+
+retract [v1.2.3, v1.2.4]
+
+retract (
+	v1.2.3
+
+	[v1.2.3, v1.2.4]
+)
diff --git a/modfile/testdata/retract.in b/modfile/testdata/retract.in
new file mode 100644
index 0000000..fa4a1f4
--- /dev/null
+++ b/modfile/testdata/retract.in
@@ -0,0 +1,11 @@
+module abc
+
+retract "v1.2.3"
+
+retract [ "v1.2.3" , "v1.2.4" ]
+
+retract (
+	"v1.2.3"
+
+	[ "v1.2.3" , "v1.2.4" ]
+)