internal/lsp/cache: refactor `go mod tidy` error logic
This CL is mostly a refactoring of the logic to compute errors for
`go mod tidy` diagnostics. It had been getting a little confusing, so
hopefully this makes things easier to read.
I made a few other small changes, such as slightly changing a few error
messages and showing diagnostics in the go.mod file for all missing
modules.
Change-Id: Ia8cf580731b997248591a2d64dff133accd9c5aa
Reviewed-on: https://go-review.googlesource.com/c/tools/+/244610
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
diff --git a/internal/lsp/cache/mod_tidy.go b/internal/lsp/cache/mod_tidy.go
index f9e57cc..5e0c1db 100644
--- a/internal/lsp/cache/mod_tidy.go
+++ b/internal/lsp/cache/mod_tidy.go
@@ -16,6 +16,7 @@
"golang.org/x/mod/modfile"
"golang.org/x/tools/internal/event"
"golang.org/x/tools/internal/lsp/debug/tag"
+ "golang.org/x/tools/internal/lsp/diff"
"golang.org/x/tools/internal/lsp/protocol"
"golang.org/x/tools/internal/lsp/source"
"golang.org/x/tools/internal/memoize"
@@ -76,9 +77,8 @@
s.mu.Unlock()
var (
- modURI = s.view.modURI
- cfg = s.config(ctx)
- options = s.view.Options()
+ modURI = s.view.modURI
+ cfg = s.config(ctx)
)
key := modTidyKey{
sessionID: s.view.session.id,
@@ -127,46 +127,17 @@
// since it has been "tidied".
return &modTidyData{err: err}
}
- // Get the dependencies that are different between the original and
- // ideal go.mod files.
- unusedDeps := make(map[string]*modfile.Require, len(pm.File.Require))
- missingDeps := make(map[string]*modfile.Require, len(ideal.Require))
- for _, req := range pm.File.Require {
- unusedDeps[req.Mod.Path] = req
- }
- for _, req := range ideal.Require {
- origDep := unusedDeps[req.Mod.Path]
- if origDep != nil && origDep.Indirect == req.Indirect {
- delete(unusedDeps, req.Mod.Path)
- } else {
- missingDeps[req.Mod.Path] = req
- }
- }
- // First, compute any errors specific to the go.mod file. These include
- // unused dependencies and modules with incorrect // indirect comments.
- /// Both the diagnostic and the fix will appear on the go.mod file.
- modRequireErrs, err := modRequireErrors(pm.Mapper, missingDeps, unusedDeps, options)
- if err != nil {
- return &modTidyData{err: err}
- }
- for _, req := range missingDeps {
- if unusedDeps[req.Mod.Path] != nil {
- delete(missingDeps, req.Mod.Path)
- }
- }
- // Next, compute any diagnostics for modules that are missing from the
- // go.mod file. The fixes will be for the go.mod file, but the
- // diagnostics should appear on the import statements in the Go or
- // go.mod files.
- missingModuleErrs, err := missingModuleErrors(ctx, snapshot, pm.Mapper, workspacePkgs, ideal.Require, missingDeps, pm.File, options)
+ // Compare the original and tidied go.mod files to compute errors and
+ // suggested fixes.
+ errors, err := modTidyErrors(ctx, snapshot, pm, ideal, workspacePkgs)
if err != nil {
return &modTidyData{err: err}
}
return &modTidyData{
tidied: &source.TidiedModule{
+ Errors: errors,
Parsed: pm,
TidiedContent: tempContents,
- Errors: append(modRequireErrs, missingModuleErrs...),
},
}
})
@@ -196,58 +167,164 @@
return hashContents([]byte(hashed)), nil
}
-// modRequireErrors extracts the errors that occur on the require directives.
-// It checks for directness issues and unused dependencies.
-func modRequireErrors(m *protocol.ColumnMapper, missingDeps, unusedDeps map[string]*modfile.Require, options source.Options) ([]source.Error, error) {
- var errors []source.Error
- for dep, req := range unusedDeps {
- if req.Syntax == nil {
+// modTidyErrors computes the differences between the original and tidied
+// go.mod files to produce diagnostic and suggested fixes. Some diagnostics
+// may appear on the Go files that import packages from missing modules.
+func modTidyErrors(ctx context.Context, snapshot source.Snapshot, pm *source.ParsedModule, ideal *modfile.File, workspacePkgs []source.Package) (errors []source.Error, err error) {
+ // First, determine which modules are unused and which are missing from the
+ // original go.mod file.
+ var (
+ unused = make(map[string]*modfile.Require, len(pm.File.Require))
+ missing = make(map[string]*modfile.Require, len(ideal.Require))
+ wrongDirectness = make(map[string]*modfile.Require, len(pm.File.Require))
+ )
+ for _, req := range pm.File.Require {
+ unused[req.Mod.Path] = req
+ }
+ for _, req := range ideal.Require {
+ origReq := unused[req.Mod.Path]
+ if origReq == nil {
+ missing[req.Mod.Path] = req
+ continue
+ } else if origReq.Indirect != req.Indirect {
+ wrongDirectness[req.Mod.Path] = origReq
+ }
+ delete(unused, req.Mod.Path)
+ }
+ for _, req := range unused {
+ srcErr, err := unusedError(pm.Mapper, req, snapshot.View().Options().ComputeEdits)
+ if err != nil {
+ return nil, err
+ }
+ errors = append(errors, srcErr)
+ }
+ for _, req := range wrongDirectness {
+ // Handle dependencies that are incorrectly labeled indirect and
+ // vice versa.
+ srcErr, err := directnessError(pm.Mapper, req, snapshot.View().Options().ComputeEdits)
+ if err != nil {
+ return nil, err
+ }
+ errors = append(errors, srcErr)
+ }
+ // Next, compute any diagnostics for modules that are missing from the
+ // go.mod file. The fixes will be for the go.mod file, but the
+ // diagnostics should also appear in both the go.mod file and the import
+ // statements in the Go files in which the dependencies are used.
+ missingModuleFixes := map[*modfile.Require][]source.SuggestedFix{}
+ for _, req := range missing {
+ srcErr, err := missingModuleError(snapshot, pm, req)
+ if err != nil {
+ return nil, err
+ }
+ missingModuleFixes[req] = srcErr.SuggestedFixes
+ errors = append(errors, srcErr)
+ }
+ // Add diagnostics for missing modules anywhere they are imported in the
+ // workspace.
+ for _, pkg := range workspacePkgs {
+ missingImports := map[string]*modfile.Require{}
+ for _, imp := range pkg.Imports() {
+ if req, ok := missing[imp.PkgPath()]; ok {
+ missingImports[imp.PkgPath()] = req
+ break
+ }
+ // If the import is a package of the dependency, then add the
+ // package to the map, this will eliminate the need to do this
+ // prefix package search on each import for each file.
+ // Example:
+ //
+ // import (
+ // "golang.org/x/tools/go/expect"
+ // "golang.org/x/tools/go/packages"
+ // )
+ // They both are related to the same module: "golang.org/x/tools".
+ var match string
+ for _, req := range ideal.Require {
+ if strings.HasPrefix(imp.PkgPath(), req.Mod.Path) && len(req.Mod.Path) > len(match) {
+ match = req.Mod.Path
+ }
+ }
+ if req, ok := missing[match]; ok {
+ missingImports[imp.PkgPath()] = req
+ }
+ }
+ // None of this package's imports are from missing modules.
+ if len(missingImports) == 0 {
continue
}
- // Handle dependencies that are incorrectly labeled indirect and vice versa.
- if missingDeps[dep] != nil && req.Indirect != missingDeps[dep].Indirect {
- directErr, err := modDirectnessErrors(m, req, options)
- if err != nil {
- return nil, err
+ for _, pgf := range pkg.CompiledGoFiles() {
+ file, m := pgf.File, pgf.Mapper
+ if file == nil || m == nil {
+ continue
}
- errors = append(errors, directErr)
- }
- // Handle unused dependencies.
- if missingDeps[dep] == nil {
- rng, err := rangeFromPositions(m, req.Syntax.Start, req.Syntax.End)
- if err != nil {
- return nil, err
+ imports := make(map[string]*ast.ImportSpec)
+ for _, imp := range file.Imports {
+ if imp.Path == nil {
+ continue
+ }
+ if target, err := strconv.Unquote(imp.Path.Value); err == nil {
+ imports[target] = imp
+ }
}
- edits, err := dropDependencyEdits(m, req, options)
- if err != nil {
- return nil, err
+ if len(imports) == 0 {
+ continue
}
- errors = append(errors, source.Error{
- Category: ModTidyError,
- Message: fmt.Sprintf("%s is not used in this module.", dep),
- Range: rng,
- URI: m.URI,
- SuggestedFixes: []source.SuggestedFix{{
- Title: fmt.Sprintf("Remove dependency: %s", dep),
- Edits: map[span.URI][]protocol.TextEdit{
- m.URI: edits,
- },
- }},
- })
+ for importPath, req := range missingImports {
+ imp, ok := imports[importPath]
+ if !ok {
+ continue
+ }
+ fixes, ok := missingModuleFixes[req]
+ if !ok {
+ return nil, fmt.Errorf("no missing module fix for %q (%q)", importPath, req.Mod.Path)
+ }
+ srcErr, err := missingModuleForImport(snapshot, m, imp, req, fixes)
+ if err != nil {
+ return nil, err
+ }
+ errors = append(errors, srcErr)
+ }
}
}
return errors, nil
}
-const ModTidyError = "go mod tidy"
-
-// modDirectnessErrors extracts errors when a dependency is labeled indirect when it should be direct and vice versa.
-func modDirectnessErrors(m *protocol.ColumnMapper, req *modfile.Require, options source.Options) (source.Error, error) {
+// unusedError returns a source.Error for an unused require.
+func unusedError(m *protocol.ColumnMapper, req *modfile.Require, computeEdits diff.ComputeEdits) (source.Error, error) {
rng, err := rangeFromPositions(m, req.Syntax.Start, req.Syntax.End)
if err != nil {
return source.Error{}, err
}
+ edits, err := dropDependency(req, m, computeEdits)
+ if err != nil {
+ return source.Error{}, err
+ }
+ return source.Error{
+ Category: source.GoModTidy,
+ Message: fmt.Sprintf("%s is not used in this module", req.Mod.Path),
+ Range: rng,
+ URI: m.URI,
+ SuggestedFixes: []source.SuggestedFix{{
+ Title: fmt.Sprintf("Remove dependency: %s", req.Mod.Path),
+ Edits: map[span.URI][]protocol.TextEdit{
+ m.URI: edits,
+ },
+ }},
+ }, nil
+}
+
+// directnessError extracts errors when a dependency is labeled indirect when
+// it should be direct and vice versa.
+func directnessError(m *protocol.ColumnMapper, req *modfile.Require, computeEdits diff.ComputeEdits) (source.Error, error) {
+ rng, err := rangeFromPositions(m, req.Syntax.Start, req.Syntax.End)
+ if err != nil {
+ return source.Error{}, err
+ }
+ direction := "indirect"
if req.Indirect {
+ direction = "direct"
+
// If the dependency should be direct, just highlight the // indirect.
if comments := req.Syntax.Comment(); comments != nil && len(comments.Suffix) > 0 {
end := comments.Suffix[0].Start
@@ -258,35 +335,19 @@
return source.Error{}, err
}
}
- edits, err := changeDirectnessEdits(m, req, false, options)
- if err != nil {
- return source.Error{}, err
- }
- return source.Error{
- Category: ModTidyError,
- Message: fmt.Sprintf("%s should be a direct dependency", req.Mod.Path),
- Range: rng,
- URI: m.URI,
- SuggestedFixes: []source.SuggestedFix{{
- Title: fmt.Sprintf("Make %s direct", req.Mod.Path),
- Edits: map[span.URI][]protocol.TextEdit{
- m.URI: edits,
- },
- }},
- }, nil
}
// If the dependency should be indirect, add the // indirect.
- edits, err := changeDirectnessEdits(m, req, true, options)
+ edits, err := switchDirectness(req, m, computeEdits)
if err != nil {
return source.Error{}, err
}
return source.Error{
- Category: ModTidyError,
- Message: fmt.Sprintf("%s should be an indirect dependency", req.Mod.Path),
+ Message: fmt.Sprintf("%s should be %s", req.Mod.Path, direction),
Range: rng,
URI: m.URI,
+ Category: source.GoModTidy,
SuggestedFixes: []source.SuggestedFix{{
- Title: fmt.Sprintf("Make %s indirect", req.Mod.Path),
+ Title: fmt.Sprintf("Change %s to %s", req.Mod.Path, direction),
Edits: map[span.URI][]protocol.TextEdit{
m.URI: edits,
},
@@ -294,19 +355,35 @@
}, nil
}
-// dropDependencyEdits gets the edits needed to remove the dependency from the go.mod file.
-// As an example, this function will codify the edits needed to convert the before go.mod file to the after.
-// Before:
-// module t
-//
-// go 1.11
-//
-// require golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee
-// After:
-// module t
-//
-// go 1.11
-func dropDependencyEdits(m *protocol.ColumnMapper, req *modfile.Require, options source.Options) ([]protocol.TextEdit, error) {
+func missingModuleError(snapshot source.Snapshot, pm *source.ParsedModule, req *modfile.Require) (source.Error, error) {
+ start, end := pm.File.Module.Syntax.Span()
+ rng, err := rangeFromPositions(pm.Mapper, start, end)
+ if err != nil {
+ return source.Error{}, err
+ }
+ edits, err := addRequireFix(pm.Mapper, req, snapshot.View().Options().ComputeEdits)
+ if err != nil {
+ return source.Error{}, err
+ }
+ fix := &source.SuggestedFix{
+ Title: "Add %s to your go.mod file",
+ Edits: map[span.URI][]protocol.TextEdit{
+ pm.Mapper.URI: edits,
+ },
+ }
+ return source.Error{
+ URI: pm.Mapper.URI,
+ Range: rng,
+ Message: fmt.Sprintf("%s is not in your go.mod file", req.Mod.Path),
+ Category: source.GoModTidy,
+ Kind: source.ModTidyError,
+ SuggestedFixes: []source.SuggestedFix{*fix},
+ }, nil
+}
+
+// dropDependency returns the edits to remove the given require from the go.mod
+// file.
+func dropDependency(req *modfile.Require, m *protocol.ColumnMapper, computeEdits diff.ComputeEdits) ([]protocol.TextEdit, error) {
// We need a private copy of the parsed go.mod file, since we're going to
// modify it.
copied, err := modfile.Parse("", m.Content, nil)
@@ -322,29 +399,13 @@
return nil, err
}
// Calculate the edits to be made due to the change.
- diff := options.ComputeEdits(m.URI, string(m.Content), string(newContent))
- edits, err := source.ToProtocolEdits(m, diff)
- if err != nil {
- return nil, err
- }
- return edits, nil
+ diff := computeEdits(m.URI, string(m.Content), string(newContent))
+ return source.ToProtocolEdits(m, diff)
}
-// changeDirectnessEdits gets the edits needed to change an indirect dependency to direct and vice versa.
-// As an example, this function will codify the edits needed to convert the before go.mod file to the after.
-// Before:
-// module t
-//
-// go 1.11
-//
-// require golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee
-// After:
-// module t
-//
-// go 1.11
-//
-// require golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee // indirect
-func changeDirectnessEdits(m *protocol.ColumnMapper, req *modfile.Require, indirect bool, options source.Options) ([]protocol.TextEdit, error) {
+// switchDirectness gets the edits needed to change an indirect dependency to
+// direct and vice versa.
+func switchDirectness(req *modfile.Require, m *protocol.ColumnMapper, computeEdits diff.ComputeEdits) ([]protocol.TextEdit, error) {
// We need a private copy of the parsed go.mod file, since we're going to
// modify it.
copied, err := modfile.Parse("", m.Content, nil)
@@ -359,7 +420,7 @@
requires = append(requires, &modfile.Require{
Mod: r.Mod,
Syntax: r.Syntax,
- Indirect: indirect,
+ Indirect: !r.Indirect,
})
continue
}
@@ -371,12 +432,54 @@
return nil, err
}
// Calculate the edits to be made due to the change.
- diff := options.ComputeEdits(m.URI, string(m.Content), string(newContent))
- edits, err := source.ToProtocolEdits(m, diff)
+ diff := computeEdits(m.URI, string(m.Content), string(newContent))
+ return source.ToProtocolEdits(m, diff)
+}
+
+// missingModuleForImport creates an error for a given import path that comes
+// from a missing module.
+func missingModuleForImport(snapshot source.Snapshot, m *protocol.ColumnMapper, imp *ast.ImportSpec, req *modfile.Require, fixes []source.SuggestedFix) (source.Error, error) {
+ if req.Syntax == nil {
+ return source.Error{}, fmt.Errorf("no syntax for %v", req)
+ }
+ spn, err := span.NewRange(snapshot.FileSet(), imp.Path.Pos(), imp.Path.End()).Span()
+ if err != nil {
+ return source.Error{}, err
+ }
+ rng, err := m.Range(spn)
+ if err != nil {
+ return source.Error{}, err
+ }
+ return source.Error{
+ Category: source.GoModTidy,
+ URI: m.URI,
+ Range: rng,
+ Message: fmt.Sprintf("%s is not in your go.mod file", req.Mod.Path),
+ Kind: source.ModTidyError,
+ SuggestedFixes: fixes,
+ }, nil
+}
+
+// addRequireFix creates edits for adding a given require to a go.mod file.
+func addRequireFix(m *protocol.ColumnMapper, req *modfile.Require, computeEdits diff.ComputeEdits) ([]protocol.TextEdit, error) {
+ // We need a private copy of the parsed go.mod file, since we're going to
+ // modify it.
+ copied, err := modfile.Parse("", m.Content, nil)
if err != nil {
return nil, err
}
- return edits, nil
+ // Calculate the quick fix edits that need to be made to the go.mod file.
+ if err := copied.AddRequire(req.Mod.Path, req.Mod.Version); err != nil {
+ return nil, err
+ }
+ copied.SortBlocks()
+ newContents, err := copied.Format()
+ if err != nil {
+ return nil, err
+ }
+ // Calculate the edits to be made due to the change.
+ diff := computeEdits(m.URI, string(m.Content), string(newContents))
+ return source.ToProtocolEdits(m, diff)
}
func rangeFromPositions(m *protocol.ColumnMapper, s, e modfile.Position) (protocol.Range, error) {
@@ -397,158 +500,3 @@
}
return m.Range(span.New(m.URI, start, end))
}
-
-// missingModuleErrors returns diagnostics for each file in each workspace
-// package that has dependencies that are not reflected in the go.mod file.
-func missingModuleErrors(ctx context.Context, snapshot *snapshot, modMapper *protocol.ColumnMapper, pkgs []source.Package, modules []*modfile.Require, missingMods map[string]*modfile.Require, original *modfile.File, options source.Options) ([]source.Error, error) {
- var moduleErrs []source.Error
- matchedMissingMods := make(map[*modfile.Require]struct{})
- for _, pkg := range pkgs {
- missingPkgs := map[string]*modfile.Require{}
- for _, imp := range pkg.Imports() {
- found := func(req *modfile.Require) {
- missingPkgs[imp.PkgPath()] = req
- matchedMissingMods[req] = struct{}{}
- }
- if req, ok := missingMods[imp.PkgPath()]; ok {
- found(req)
- break
- }
- // If the import is a package of the dependency, then add the
- // package to the map, this will eliminate the need to do this
- // prefix package search on each import for each file.
- // Example:
- //
- // import (
- // "golang.org/x/tools/go/expect"
- // "golang.org/x/tools/go/packages"
- // )
- // They both are related to the same module: "golang.org/x/tools".
- var match string
- for _, mod := range modules {
- if strings.HasPrefix(imp.PkgPath(), mod.Mod.Path) && len(mod.Mod.Path) > len(match) {
- match = mod.Mod.Path
- }
- }
- if req, ok := missingMods[match]; ok {
- found(req)
- }
- }
- if len(missingPkgs) > 0 {
- errs, err := missingModules(ctx, snapshot, modMapper, pkg, missingPkgs, options)
- if err != nil {
- return nil, err
- }
- moduleErrs = append(moduleErrs, errs...)
- }
- }
- for _, req := range missingMods {
- if _, ok := matchedMissingMods[req]; ok {
- continue
- }
- s, e := original.Module.Syntax.Span()
- rng, err := rangeFromPositions(modMapper, s, e)
- if err != nil {
- return nil, err
- }
- edits, err := addRequireFix(modMapper, req, options)
- if err != nil {
- return nil, err
- }
- moduleErrs = append(moduleErrs, source.Error{
- URI: modMapper.URI,
- Range: rng,
- Message: fmt.Sprintf("%s is not in your go.mod file", req.Mod.Path),
- Category: "go mod tidy",
- Kind: source.ModTidyError,
- SuggestedFixes: []source.SuggestedFix{
- {
- Title: "Add %s to your go.mod file",
- Edits: edits,
- },
- },
- })
- }
- return moduleErrs, nil
-}
-
-func missingModules(ctx context.Context, snapshot *snapshot, modMapper *protocol.ColumnMapper, pkg source.Package, missing map[string]*modfile.Require, options source.Options) ([]source.Error, error) {
- var errors []source.Error
- for _, pgf := range pkg.CompiledGoFiles() {
- imports := make(map[string]*ast.ImportSpec)
- for _, imp := range pgf.File.Imports {
- if imp.Path == nil {
- continue
- }
- if target, err := strconv.Unquote(imp.Path.Value); err == nil {
- imports[target] = imp
- }
- }
- if len(imports) == 0 {
- continue
- }
- for mod, req := range missing {
- if req.Syntax == nil {
- continue
- }
- imp, ok := imports[mod]
- if !ok {
- continue
- }
- spn, err := span.NewRange(snapshot.view.session.cache.fset, imp.Path.Pos(), imp.Path.End()).Span()
- if err != nil {
- return nil, err
- }
- rng, err := pgf.Mapper.Range(spn)
- if err != nil {
- return nil, err
- }
- edits, err := addRequireFix(modMapper, req, options)
- if err != nil {
- return nil, err
- }
- errors = append(errors, source.Error{
- URI: pgf.URI,
- Range: rng,
- Message: fmt.Sprintf("%s is not in your go.mod file", req.Mod.Path),
- Category: "go mod tidy",
- Kind: source.ModTidyError,
- SuggestedFixes: []source.SuggestedFix{
- {
- Title: "Add %s to your go.mod file",
- Edits: edits,
- },
- },
- })
- }
- }
- return errors, nil
-}
-
-// addRequireFix creates edits for adding a given require to a go.mod file.
-func addRequireFix(m *protocol.ColumnMapper, req *modfile.Require, options source.Options) (map[span.URI][]protocol.TextEdit, error) {
- // We need a private copy of the parsed go.mod file, since we're going to
- // modify it.
- copied, err := modfile.Parse("", m.Content, nil)
- if err != nil {
- return nil, err
- }
- // Calculate the quick fix edits that need to be made to the go.mod file.
- if err := copied.AddRequire(req.Mod.Path, req.Mod.Version); err != nil {
- return nil, err
- }
- copied.SortBlocks()
- newContents, err := copied.Format()
- if err != nil {
- return nil, err
- }
- // Calculate the edits to be made due to the change.
- diff := options.ComputeEdits(m.URI, string(m.Content), string(newContents))
- edits, err := source.ToProtocolEdits(m, diff)
- if err != nil {
- return nil, err
- }
- return map[span.URI][]protocol.TextEdit{
- m.URI: edits,
- }, nil
-}
diff --git a/internal/lsp/source/view.go b/internal/lsp/source/view.go
index 76495c5..37c72c6 100644
--- a/internal/lsp/source/view.go
+++ b/internal/lsp/source/view.go
@@ -459,6 +459,9 @@
Related []RelatedInformation
}
+// GoModTidy is the source for a diagnostic computed by running `go mod tidy`.
+const GoModTidy = "go mod tidy"
+
type ErrorKind int
const (
diff --git a/internal/lsp/testdata/unused/primarymod/go.mod b/internal/lsp/testdata/unused/primarymod/go.mod
index 78035bd..12e9ade 100644
--- a/internal/lsp/testdata/unused/primarymod/go.mod
+++ b/internal/lsp/testdata/unused/primarymod/go.mod
@@ -2,4 +2,4 @@
go 1.12
-require example.com/extramodule v1.0.0 //@diag("require example.com/extramodule v1.0.0", "go mod tidy", "example.com/extramodule is not used in this module.", "warning"),suggestedfix("require example.com/extramodule v1.0.0", "quickfix")
+require example.com/extramodule v1.0.0 //@diag("require example.com/extramodule v1.0.0", "go mod tidy", "example.com/extramodule is not used in this module", "warning"),suggestedfix("require example.com/extramodule v1.0.0", "quickfix")