modfile: add API for godebug lines

For golang/go#65573

Change-Id: I5c1be8833f70b0b5a7257bd5216fa6a89bd2665f
Reviewed-on: https://go-review.googlesource.com/c/mod/+/584300
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Russ Cox <rsc@golang.org>
Reviewed-by: Michael Matloob <matloob@golang.org>
Reviewed-by: Sam Thanawalla <samthanawalla@google.com>
diff --git a/modfile/rule.go b/modfile/rule.go
index 0e7b7e2..66dcaf9 100644
--- a/modfile/rule.go
+++ b/modfile/rule.go
@@ -38,6 +38,7 @@
 	Module    *Module
 	Go        *Go
 	Toolchain *Toolchain
+	Godebug   []*Godebug
 	Require   []*Require
 	Exclude   []*Exclude
 	Replace   []*Replace
@@ -65,6 +66,13 @@
 	Syntax *Line
 }
 
+// A Godebug is a single godebug key=value statement.
+type Godebug struct {
+	Key    string
+	Value  string
+	Syntax *Line
+}
+
 // An Exclude is a single exclude statement.
 type Exclude struct {
 	Mod    module.Version
@@ -289,7 +297,7 @@
 					})
 				}
 				continue
-			case "module", "require", "exclude", "replace", "retract":
+			case "module", "godebug", "require", "exclude", "replace", "retract":
 				for _, l := range x.Line {
 					f.add(&errs, x, l, x.Token[0], l.Token, fix, strict)
 				}
@@ -308,7 +316,9 @@
 
 // Toolchains must be named beginning with `go1`,
 // like "go1.20.3" or "go1.20.3-gccgo". As a special case, "default" is also permitted.
-// TODO(samthanawalla): Replace regex with https://pkg.go.dev/go/version#IsValid in 1.23+
+// Note that this regexp is a much looser condition than go/version.IsValid,
+// for forward compatibility.
+// (This code has to be work to identify new toolchains even if we tweak the syntax in the future.)
 var ToolchainRE = lazyregexp.New(`^default$|^go1($|\.)`)
 
 func (f *File) add(errs *ErrorList, block *LineBlock, line *Line, verb string, args []string, fix VersionFixer, strict bool) {
@@ -384,7 +394,7 @@
 		if len(args) != 1 {
 			errorf("toolchain directive expects exactly one argument")
 			return
-		} else if strict && !ToolchainRE.MatchString(args[0]) {
+		} else if !ToolchainRE.MatchString(args[0]) {
 			errorf("invalid toolchain version '%s': must match format go1.23.0 or default", args[0])
 			return
 		}
@@ -412,6 +422,22 @@
 		}
 		f.Module.Mod = module.Version{Path: s}
 
+	case "godebug":
+		if len(args) != 1 || strings.ContainsAny(args[0], "\"`',") {
+			errorf("usage: godebug key=value")
+			return
+		}
+		key, value, ok := strings.Cut(args[0], "=")
+		if !ok {
+			errorf("usage: godebug key=value")
+			return
+		}
+		f.Godebug = append(f.Godebug, &Godebug{
+			Key:    key,
+			Value:  value,
+			Syntax: line,
+		})
+
 	case "require", "exclude":
 		if len(args) != 2 {
 			errorf("usage: %s module/path v1.2.3", verb)
@@ -654,6 +680,22 @@
 		f.Toolchain = &Toolchain{Syntax: line}
 		f.Toolchain.Name = args[0]
 
+	case "godebug":
+		if len(args) != 1 || strings.ContainsAny(args[0], "\"`',") {
+			errorf("usage: godebug key=value")
+			return
+		}
+		key, value, ok := strings.Cut(args[0], "=")
+		if !ok {
+			errorf("usage: godebug key=value")
+			return
+		}
+		f.Godebug = append(f.Godebug, &Godebug{
+			Key:    key,
+			Value:  value,
+			Syntax: line,
+		})
+
 	case "use":
 		if len(args) != 1 {
 			errorf("usage: %s local/dir", verb)
@@ -929,6 +971,15 @@
 // Cleanup cleans out all the cleared entries.
 func (f *File) Cleanup() {
 	w := 0
+	for _, g := range f.Godebug {
+		if g.Key != "" {
+			f.Godebug[w] = g
+			w++
+		}
+	}
+	f.Godebug = f.Godebug[:w]
+
+	w = 0
 	for _, r := range f.Require {
 		if r.Mod.Path != "" {
 			f.Require[w] = r
@@ -1027,6 +1078,45 @@
 	return nil
 }
 
+// AddGodebug sets the first godebug line for key to value,
+// preserving any existing comments for that line and removing all
+// other godebug lines for key.
+//
+// If no line currently exists for key, AddGodebug adds a new line
+// at the end of the last godebug block.
+func (f *File) AddGodebug(key, value string) error {
+	need := true
+	for _, g := range f.Godebug {
+		if g.Key == key {
+			if need {
+				g.Value = value
+				f.Syntax.updateLine(g.Syntax, "godebug", key+"="+value)
+				need = false
+			} else {
+				g.Syntax.markRemoved()
+				*g = Godebug{}
+			}
+		}
+	}
+
+	if need {
+		f.addNewGodebug(key, value)
+	}
+	return nil
+}
+
+// addNewGodebug adds a new godebug key=value line at the end
+// of the last godebug block, regardless of any existing godebug lines for key.
+func (f *File) addNewGodebug(key, value string) {
+	line := f.Syntax.addLine(nil, "godebug", key+"="+value)
+	g := &Godebug{
+		Key:    key,
+		Value:  value,
+		Syntax: line,
+	}
+	f.Godebug = append(f.Godebug, g)
+}
+
 // AddRequire sets the first require line for path to version vers,
 // preserving any existing comments for that line and removing all
 // other lines for path.
@@ -1334,6 +1424,16 @@
 	f.SortBlocks()
 }
 
+func (f *File) DropGodebug(key string) error {
+	for _, g := range f.Godebug {
+		if g.Key == key {
+			g.Syntax.markRemoved()
+			*g = Godebug{}
+		}
+	}
+	return nil
+}
+
 func (f *File) DropRequire(path string) error {
 	for _, r := range f.Require {
 		if r.Mod.Path == path {
diff --git a/modfile/rule_test.go b/modfile/rule_test.go
index ca11d17..4d0d12a 100644
--- a/modfile/rule_test.go
+++ b/modfile/rule_test.go
@@ -1581,6 +1581,139 @@
 	},
 }
 
+var addGodebugTests = []struct {
+	desc  string
+	in    string
+	key   string
+	value string
+	out   string
+}{
+	{
+		`existing`,
+		`
+		module m
+		godebug key=old
+		`,
+		"key", "new",
+		`
+		module m
+		godebug key=new
+		`,
+	},
+	{
+		`existing2`,
+		`
+		module m
+		godebug (
+			key=first // first
+			other=first-a // first-a
+		)
+		godebug key=second // second
+		godebug (
+			key=third // third
+			other=third-a // third-a
+		)
+		`,
+		"key", "new",
+		`
+		module m
+
+		godebug (
+			key=new // first
+			other=first-a// first-a
+		)
+
+		godebug other=third-a // third-a
+		`,
+	},
+	{
+		`new`,
+		`
+		module m
+		godebug other=foo
+		`,
+		"key", "new",
+		`
+		module m
+		godebug (
+			other=foo
+			key=new
+		)
+		`,
+	},
+	{
+		`new2`,
+		`
+		module m
+		godebug first=1
+		godebug second=2
+		`,
+		"third", "3",
+		`
+		module m
+		godebug first=1
+		godebug (
+			second=2
+			third=3
+		)
+		`,
+	},
+}
+
+var dropGodebugTests = []struct {
+	desc string
+	in   string
+	key  string
+	out  string
+}{
+	{
+		`existing`,
+		`
+		module m
+		godebug key=old
+		`,
+		"key",
+		`
+		module m
+		`,
+	},
+	{
+		`existing2`,
+		`
+		module m
+		godebug (
+			key=first // first
+			other=first-a // first-a
+		)
+		godebug key=second // second
+		godebug (
+			key=third // third
+			other=third-a // third-a
+		)
+		`,
+		"key",
+		`
+		module m
+
+		godebug other=first-a// first-a
+
+		godebug other=third-a // third-a
+		`,
+	},
+	{
+		`new`,
+		`
+		module m
+		godebug other=foo
+		`,
+		"key",
+		`
+		module m
+		godebug other=foo
+		`,
+	},
+}
+
 func fixV(path, version string) (string, error) {
 	if path != "example.com/m" {
 		return "", fmt.Errorf("module path must be example.com/m")
@@ -1600,6 +1733,18 @@
 	}
 }
 
+func TestAddGodebug(t *testing.T) {
+	for _, tt := range addGodebugTests {
+		t.Run(tt.desc, func(t *testing.T) {
+			testEdit(t, tt.in, tt.out, true, func(f *File) error {
+				err := f.AddGodebug(tt.key, tt.value)
+				f.Cleanup()
+				return err
+			})
+		})
+	}
+}
+
 func TestSetRequire(t *testing.T) {
 	for _, tt := range setRequireTests {
 		t.Run(tt.desc, func(t *testing.T) {
@@ -1696,6 +1841,18 @@
 	}
 }
 
+func TestDropGodebug(t *testing.T) {
+	for _, tt := range dropGodebugTests {
+		t.Run(tt.desc, func(t *testing.T) {
+			testEdit(t, tt.in, tt.out, true, func(f *File) error {
+				f.DropGodebug(tt.key)
+				f.Cleanup()
+				return nil
+			})
+		})
+	}
+}
+
 func TestAddExclude(t *testing.T) {
 	for _, tt := range addExcludeTests {
 		t.Run(tt.desc, func(t *testing.T) {
diff --git a/modfile/work.go b/modfile/work.go
index d7b9937..8f54897 100644
--- a/modfile/work.go
+++ b/modfile/work.go
@@ -14,6 +14,7 @@
 type WorkFile struct {
 	Go        *Go
 	Toolchain *Toolchain
+	Godebug   []*Godebug
 	Use       []*Use
 	Replace   []*Replace
 
@@ -68,7 +69,7 @@
 					Err:      fmt.Errorf("unknown block type: %s", strings.Join(x.Token, " ")),
 				})
 				continue
-			case "use", "replace":
+			case "godebug", "use", "replace":
 				for _, l := range x.Line {
 					f.add(&errs, l, x.Token[0], l.Token, fix)
 				}
@@ -184,6 +185,55 @@
 	}
 }
 
+// AddGodebug sets the first godebug line for key to value,
+// preserving any existing comments for that line and removing all
+// other godebug lines for key.
+//
+// If no line currently exists for key, AddGodebug adds a new line
+// at the end of the last godebug block.
+func (f *WorkFile) AddGodebug(key, value string) error {
+	need := true
+	for _, g := range f.Godebug {
+		if g.Key == key {
+			if need {
+				g.Value = value
+				f.Syntax.updateLine(g.Syntax, "godebug", key+"="+value)
+				need = false
+			} else {
+				g.Syntax.markRemoved()
+				*g = Godebug{}
+			}
+		}
+	}
+
+	if need {
+		f.addNewGodebug(key, value)
+	}
+	return nil
+}
+
+// addNewGodebug adds a new godebug key=value line at the end
+// of the last godebug block, regardless of any existing godebug lines for key.
+func (f *WorkFile) addNewGodebug(key, value string) {
+	line := f.Syntax.addLine(nil, "godebug", key+"="+value)
+	g := &Godebug{
+		Key:    key,
+		Value:  value,
+		Syntax: line,
+	}
+	f.Godebug = append(f.Godebug, g)
+}
+
+func (f *WorkFile) DropGodebug(key string) error {
+	for _, g := range f.Godebug {
+		if g.Key == key {
+			g.Syntax.markRemoved()
+			*g = Godebug{}
+		}
+	}
+	return nil
+}
+
 func (f *WorkFile) AddUse(diskPath, modulePath string) error {
 	need := true
 	for _, d := range f.Use {
diff --git a/modfile/work_test.go b/modfile/work_test.go
index dcc0810..b4b4e7e 100644
--- a/modfile/work_test.go
+++ b/modfile/work_test.go
@@ -352,6 +352,34 @@
 	}
 }
 
+func TestWorkAddGodebug(t *testing.T) {
+	for _, tt := range addGodebugTests {
+		t.Run(tt.desc, func(t *testing.T) {
+			in := strings.ReplaceAll(tt.in, "module m", "use foo")
+			out := strings.ReplaceAll(tt.out, "module m", "use foo")
+			testWorkEdit(t, in, out, func(f *WorkFile) error {
+				err := f.AddGodebug(tt.key, tt.value)
+				f.Cleanup()
+				return err
+			})
+		})
+	}
+}
+
+func TestWorkDropGodebug(t *testing.T) {
+	for _, tt := range dropGodebugTests {
+		t.Run(tt.desc, func(t *testing.T) {
+			in := strings.ReplaceAll(tt.in, "module m", "use foo")
+			out := strings.ReplaceAll(tt.out, "module m", "use foo")
+			testWorkEdit(t, in, out, func(f *WorkFile) error {
+				f.DropGodebug(tt.key)
+				f.Cleanup()
+				return nil
+			})
+		})
+	}
+}
+
 // Test that when files in the testdata directory are parsed
 // and printed and parsed again, we get the same parse tree
 // both times.