modfile: use semantic sort for exclude blocks

For golang/go#60028.

Change-Id: I4c7a726a900fc7c4b34816eba5cfd0361c45315f
Reviewed-on: https://go-review.googlesource.com/c/mod/+/492990
Run-TryBot: Dmitri Shuralyov <dmitshur@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
Auto-Submit: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
Reviewed-by: Bryan Mills <bcmills@google.com>
diff --git a/modfile/rule.go b/modfile/rule.go
index 6bcde8f..c20aef1 100644
--- a/modfile/rule.go
+++ b/modfile/rule.go
@@ -1387,13 +1387,21 @@
 func (f *File) SortBlocks() {
 	f.removeDups() // otherwise sorting is unsafe
 
+	// semanticSortForExcludeVersionV is the Go version (plus leading "v") at which
+	// lines in exclude blocks start to use semantic sort instead of lexicographic sort.
+	// See go.dev/issue/60028.
+	const semanticSortForExcludeVersionV = "v1.21"
+	useSemanticSortForExclude := f.Go != nil && semver.Compare("v"+f.Go.Version, semanticSortForExcludeVersionV) >= 0
+
 	for _, stmt := range f.Syntax.Stmt {
 		block, ok := stmt.(*LineBlock)
 		if !ok {
 			continue
 		}
 		less := lineLess
-		if block.Token[0] == "retract" {
+		if block.Token[0] == "exclude" && useSemanticSortForExclude {
+			less = lineExcludeLess
+		} else if block.Token[0] == "retract" {
 			less = lineRetractLess
 		}
 		sort.SliceStable(block.Line, func(i, j int) bool {
@@ -1496,6 +1504,22 @@
 	return len(li.Token) < len(lj.Token)
 }
 
+// lineExcludeLess reports whether li should be sorted before lj for lines in
+// an "exclude" block.
+func lineExcludeLess(li, lj *Line) bool {
+	if len(li.Token) != 2 || len(lj.Token) != 2 {
+		// Not a known exclude specification.
+		// Fall back to sorting lexicographically.
+		return lineLess(li, lj)
+	}
+	// An exclude specification has two tokens: ModulePath and Version.
+	// Compare module path by string order and version by semver rules.
+	if pi, pj := li.Token[0], lj.Token[0]; pi != pj {
+		return pi < pj
+	}
+	return semver.Compare(li.Token[1], lj.Token[1]) < 0
+}
+
 // 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.
diff --git a/modfile/rule_test.go b/modfile/rule_test.go
index 26879fb..f8dd174 100644
--- a/modfile/rule_test.go
+++ b/modfile/rule_test.go
@@ -1224,6 +1224,52 @@
 		`,
 		false,
 	},
+	// Exclude blocks are sorted using semver in ascending order
+	// in go.mod files that opt in to Go version 1.21 or newer.
+	{
+		`sort_exclude_go121_semver`,
+		`module m
+		go 1.21
+		exclude (
+			b.example/m v0.9.0
+			a.example/m v1.0.0
+			b.example/m v0.10.0
+			c.example/m v1.1.0
+			b.example/m v0.11.0
+		)`,
+		`module m
+		go 1.21
+		exclude (
+			a.example/m v1.0.0
+			b.example/m v0.9.0
+			b.example/m v0.10.0
+			b.example/m v0.11.0
+			c.example/m v1.1.0
+		)
+		`,
+		true,
+	},
+	{
+		`sort_exclude_!go121_lexicographically`, // Maintain the previous (less featureful) behavior to avoid unnecessary churn.
+		`module m
+		exclude (
+			b.example/m v0.9.0
+			a.example/m v1.0.0
+			b.example/m v0.10.0
+			c.example/m v1.1.0
+			b.example/m v0.11.0
+		)`,
+		`module m
+		exclude (
+			a.example/m v1.0.0
+			b.example/m v0.10.0
+			b.example/m v0.11.0
+			b.example/m v0.9.0
+			c.example/m v1.1.0
+		)
+		`,
+		true,
+	},
 }
 
 var addRetractValidateVersionTests = []struct {