From 258150012b857db69095377d326932d1050b4b55 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 17 Jun 2026 22:16:19 -0700 Subject: [PATCH 01/19] Go mod tidy recipe + module-graph resolution (modgraph, proxy/HttpSender, recipe-time refresh) --- .../java/org/openrewrite/rpc/RewriteRpc.java | 70 +++ rewrite-go/cmd/rpc/main.go | 103 ++++ rewrite-go/pkg/parser/modgraph/marker.go | 85 +++ rewrite-go/pkg/parser/modgraph/modgraph.go | 285 ++++++++++ .../pkg/parser/modgraph/modgraph_test.go | 424 +++++++++++++++ rewrite-go/pkg/parser/modgraph/needed.go | 210 ++++++++ rewrite-go/pkg/parser/modgraph/source.go | 288 ++++++++++ rewrite-go/pkg/recipe/golang/activate.go | 1 + rewrite-go/pkg/recipe/golang/go_mod_tidy.go | 491 ++++++++++++++++++ .../pkg/recipe/golang/module_resolution.go | 107 ++++ .../golang/module_resolution_visitor.go | 54 ++ .../pkg/rpc/go_resolution_result_codec.go | 102 ++++ rewrite-go/pkg/rpc/gomod_codec_test.go | 58 +++ rewrite-go/pkg/rpc/value_types.go | 4 + .../pkg/tree/golang/go_resolution_result.go | 42 ++ .../org/openrewrite/golang/GoModParser.java | 7 +- .../golang/marker/GoResolutionResult.java | 119 ++++- .../openrewrite/golang/rpc/GoRewriteRpc.java | 4 + rewrite-go/test/go_mod_resolution_test.go | 131 +++++ rewrite-go/test/go_mod_tidy_recipe_test.go | 96 ++++ 20 files changed, 2679 insertions(+), 2 deletions(-) create mode 100644 rewrite-go/pkg/parser/modgraph/marker.go create mode 100644 rewrite-go/pkg/parser/modgraph/modgraph.go create mode 100644 rewrite-go/pkg/parser/modgraph/modgraph_test.go create mode 100644 rewrite-go/pkg/parser/modgraph/needed.go create mode 100644 rewrite-go/pkg/parser/modgraph/source.go create mode 100644 rewrite-go/pkg/recipe/golang/go_mod_tidy.go create mode 100644 rewrite-go/pkg/recipe/golang/module_resolution.go create mode 100644 rewrite-go/pkg/recipe/golang/module_resolution_visitor.go create mode 100644 rewrite-go/test/go_mod_resolution_test.go create mode 100644 rewrite-go/test/go_mod_tidy_recipe_test.go diff --git a/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java b/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java index b8eb1b54aa5..8a06df76eb7 100644 --- a/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java +++ b/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java @@ -36,7 +36,16 @@ import org.openrewrite.tree.ParsingEventListener; import org.openrewrite.tree.ParsingExecutionContextView; +import org.openrewrite.HttpSenderExecutionContextView; +import org.openrewrite.ipc.http.HttpSender; +import org.openrewrite.ipc.http.HttpUrlConnectionSender; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; import java.io.PrintStream; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -62,6 +71,7 @@ @SuppressWarnings("UnusedReturnValue") public class RewriteRpc { private final JsonRpc jsonRpc; + private volatile @Nullable HttpSender httpSender; private final AtomicInteger batchSize = new AtomicInteger(1000); private Duration timeout = Duration.ofSeconds(30); private Supplier livenessCheck = () -> null; @@ -233,9 +243,54 @@ protected Boolean handle(Void noParams) { } }); + // Http: lets the RPC peer (e.g. the Go module-graph resolver fetching + // dependency go.mod files from a GOPROXY) perform an HTTP GET through + // the configured HttpSender, so proxy/auth/TLS are honored. Returns the + // status code and base64-encoded body. + jsonRpc.rpc("Http", new JsonRpcMethod() { + @Override + protected Object handle(HttpRequest request) { + HttpSender sender = httpSender != null ? httpSender : new HttpUrlConnectionSender(); + Map out = new HashMap<>(); + try (HttpSender.Response response = sender.get(request.url).send()) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (InputStream in = response.getBody()) { + byte[] buf = new byte[8192]; + for (int n; (n = in.read(buf)) != -1; ) { + bos.write(buf, 0, n); + } + } + out.put("status", response.getCode()); + out.put("body", Base64.getEncoder().encodeToString(bos.toByteArray())); + } catch (Exception e) { + out.put("status", 0); + out.put("body", ""); + } + return out; + } + }); + jsonRpc.bind(); } + /** + * Configure the {@link HttpSender} used by the {@code Http} RPC method. + * Typically set from a parse/recipe ExecutionContext so the peer's HTTP + * fetches use the CLI-configured sender (proxy, auth, TLS). + */ + public RewriteRpc setHttpSender(@Nullable HttpSender httpSender) { + this.httpSender = httpSender; + return this; + } + + /** + * Request body for the {@code Http} RPC method. + */ + static class HttpRequest { + public String url; + public @Nullable String method; + } + public RewriteRpc livenessCheck(Supplier livenessCheck) { this.livenessCheck = livenessCheck; return this; @@ -286,7 +341,20 @@ public void reset() { return visit(sourceFile, visitorName, p, null); } + // Make the HttpSender from the current operation's ExecutionContext available + // to the peer's Http RPC method (e.g. the Go module-graph resolver fetching + // from a GOPROXY at recipe time). + private void setHttpSenderFrom(@Nullable Object p) { + if (p instanceof ExecutionContext) { + HttpSender sender = HttpSenderExecutionContextView.view((ExecutionContext) p).getHttpSender(); + if (sender != null) { + this.httpSender = sender; + } + } + } + public

@Nullable Tree visit(Tree tree, String visitorName, P p, @Nullable Cursor cursor) { + setHttpSenderFrom(p); // Set the local state of this tree, so that when the remote asks for it, we know what to send. localObjects.put(tree.getId().toString(), tree); @@ -307,6 +375,7 @@ public void reset() { public

BatchVisitResponse batchVisit(Tree tree, P p, @Nullable Cursor cursor, List visitors) { + setHttpSenderFrom(p); String treeId = tree.getId().toString(); localObjects.put(treeId, tree); @@ -324,6 +393,7 @@ public

BatchVisitResponse batchVisit(Tree tree, P p, @Nullable Cursor cursor } public Collection generate(String remoteRecipeId, ExecutionContext ctx) { + setHttpSenderFrom(ctx); String ctxId = maybeUnwrapExecutionContext(ctx); GenerateResponse response = RewriteRpcExecutionContextView.view(ctx).withInFlightSlot(() -> send("Generate", new Generate(remoteRecipeId, ctxId), GenerateResponse.class)); diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index 3592ad692b7..74fc4bed94a 100644 --- a/rewrite-go/cmd/rpc/main.go +++ b/rewrite-go/cmd/rpc/main.go @@ -20,6 +20,7 @@ package main import ( "bufio" + "encoding/base64" "encoding/csv" "encoding/json" "flag" @@ -39,6 +40,7 @@ import ( "github.com/grafana/pyroscope-go" goparser "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" "github.com/openrewrite/rewrite/rewrite-go/pkg/preconditions" "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" @@ -123,6 +125,9 @@ type server struct { reader *bufio.Reader writer io.Writer + // httpMu serializes bidirectional Http requests so concurrent fetches do + // not interleave on the single duplex stream. + httpMu sync.Mutex logger *log.Logger registry *recipe.Registry installer *installer.Installer @@ -816,6 +821,97 @@ func mapMarkerPrinter(mp *string) printer.MarkerPrinter { } } +// fetchHTTP performs an HTTP GET by delegating to the Java side over +// bidirectional RPC, which executes the request through the CLI-configured +// OpenRewrite HttpSender (proxy, auth, TLS all honored). Returns the response +// body and status code. Used by the module-graph resolver to fetch dependency +// go.mod files from a GOPROXY when they are not in the local module cache. +func (s *server) fetchHTTP(url string) ([]byte, int, error) { + s.httpMu.Lock() + defer s.httpMu.Unlock() + params, _ := json.Marshal(map[string]any{"url": url, "method": "GET"}) + req, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": "go-Http", + "method": "Http", + "params": json.RawMessage(params), + }) + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(req)) + if _, err := s.writer.Write(append([]byte(header), req...)); err != nil { + return nil, 0, err + } + resp, err := s.readMessage() + if err != nil { + return nil, 0, err + } + raw := resp.Result + if raw == nil { + raw = resp.Params + } + if raw == nil { + return nil, 0, fmt.Errorf("Http: empty response") + } + var out struct { + Status int `json:"status"` + Body string `json:"body"` // base64 + } + if err := json.Unmarshal(raw, &out); err != nil { + return nil, 0, err + } + body, err := base64.StdEncoding.DecodeString(out.Body) + if err != nil { + return nil, 0, err + } + return body, out.Status, nil +} + +// resolveModuleGraph enriches a go.mod's GoResolutionResult marker with the +// resolved module graph + build list. Dependency go.mod files are read from the +// local module cache first and fetched from the GOPROXY (via the Java +// HttpSender) on a miss. Best-effort: on any failure the marker keeps whatever +// was resolved and GraphComplete reflects partiality. +// +// Proxy fetching is opt-in via MODERNE_GO_PROXY_RESOLVE to avoid unexpected +// network during parsing; without it, only the local cache is consulted. +func (s *server) resolveModuleGraph(goModContent []byte, mrr *golang.GoResolutionResult) { + res, err := modgraph.Resolve(goModContent, s.moduleSource()) + if err != nil { + s.logger.Printf("resolveModuleGraph: %v", err) + return + } + modgraph.ApplyTo(res, mrr) +} + +// moduleSource builds the dependency-resolution source: the local module cache +// first, then (when MODERNE_GO_PROXY_RESOLVE is set) a GOPROXY tier whose HTTP +// is delegated to the Java HttpSender via the bidirectional Http method. The +// same source is used at parse time and installed into the recipe +// ExecutionContext for recipe-time re-resolution. +func (s *server) moduleSource() modgraph.ModSource { + sources := []modgraph.ModSource{modgraph.CacheSource(envOr("GOMODCACHE", defaultModCache()))} + if os.Getenv("MODERNE_GO_PROXY_RESOLVE") != "" { + sources = append(sources, modgraph.ProxySource(envOr("GOPROXY", "https://proxy.golang.org,direct"), s.fetchHTTP)) + } + return modgraph.TieredSource(sources...) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func defaultModCache() string { + if gp := os.Getenv("GOPATH"); gp != "" { + return filepath.Join(strings.SplitN(gp, string(os.PathListSeparator), 2)[0], "pkg", "mod") + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, "go", "pkg", "mod") + } + return "" +} + // getObjectFromJava fetches an object from the Java side via bidirectional RPC. // For Print, Java holds the (potentially modified) tree. We need to request it back. // Supports multi-batch transfers: each GetObject call returns one batch, and @@ -1042,6 +1138,10 @@ func (s *server) resolveExecutionContext(pid *string) *recipe.ExecutionContext { s.preparedContexts[*pid] = ctx } s.installDataTableStore(ctx) + // Make the dependency-resolution source available so recipes that mutate + // dependencies can re-resolve the module model at recipe time (network via + // the Java HttpSender). + recipes.SetModSource(ctx, s.moduleSource()) return ctx } @@ -1953,6 +2053,9 @@ func (s *server) handleParseProject(params json.RawMessage) (any, *rpcError) { if sumData, err := os.ReadFile(sumPath); err == nil { mrr.ResolvedDependencies = goparser.ParseGoSum(string(sumData)) } + // Enrich with the resolved module graph + build list (cache, then + // GOPROXY via the Java HttpSender) so recipes get the full graph. + s.resolveModuleGraph(data, mrr) mods[filepath.Dir(modPath)] = &modCtx{dir: filepath.Dir(modPath), mrr: mrr} } diff --git a/rewrite-go/pkg/parser/modgraph/marker.go b/rewrite-go/pkg/parser/modgraph/marker.go new file mode 100644 index 00000000000..96336242bb0 --- /dev/null +++ b/rewrite-go/pkg/parser/modgraph/marker.go @@ -0,0 +1,85 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package modgraph + +import "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" + +// ToResolutionResult folds a resolved Result into a GoResolutionResult marker, +// populating the module-graph fields (BuildList/Graph/GraphComplete). The +// caller supplies the module identity fields parsed from go.mod. This is the +// single mapping used by both the parser wiring and the parity harness. +func ToResolutionResult(res Result, modulePath, goVersion, toolchain, path string) golang.GoResolutionResult { + m := golang.NewGoResolutionResult(modulePath, goVersion, toolchain, path) + ApplyTo(res, &m) + return m +} + +// FromMarker reconstructs a resolver Result from the module-graph fields of a +// GoResolutionResult marker, so recipes can run NeededModules (the package-import +// graph) at recipe time against the parse-time-resolved graph without re-fetching +// dependency go.mod files. +func FromMarker(m golang.GoResolutionResult) Result { + res := Result{Complete: m.GraphComplete} + for _, b := range m.BuildList { + res.BuildList = append(res.BuildList, Module{ + Path: b.ModulePath, + Version: b.Version, + GoVersion: b.GoVersion, + Main: b.Main, + ModuleHash: b.ModuleHash, + GoModHash: b.GoModHash, + }) + } + for _, e := range m.Graph { + res.Graph = append(res.Graph, Edge{ + FromPath: e.FromPath, + FromVersion: e.FromVersion, + ToPath: e.ToPath, + ToVersion: e.ToVersion, + Indirect: e.Indirect, + }) + } + return res +} + +// ApplyTo populates the module-graph fields (BuildList/Graph/GraphComplete) of +// an existing GoResolutionResult marker from a resolved Result. Used by the RPC +// parser to enrich the marker it already built from the go.mod text. +func ApplyTo(res Result, m *golang.GoResolutionResult) { + m.BuildList = make([]golang.GoModule, 0, len(res.BuildList)) + for _, b := range res.BuildList { + m.BuildList = append(m.BuildList, golang.GoModule{ + ModulePath: b.Path, + Version: b.Version, + GoVersion: b.GoVersion, + Main: b.Main, + ModuleHash: b.ModuleHash, + GoModHash: b.GoModHash, + }) + } + m.Graph = make([]golang.GoModuleEdge, 0, len(res.Graph)) + for _, e := range res.Graph { + m.Graph = append(m.Graph, golang.GoModuleEdge{ + FromPath: e.FromPath, + FromVersion: e.FromVersion, + ToPath: e.ToPath, + ToVersion: e.ToVersion, + Indirect: e.Indirect, + }) + } + m.GraphComplete = res.Complete +} diff --git a/rewrite-go/pkg/parser/modgraph/modgraph.go b/rewrite-go/pkg/parser/modgraph/modgraph.go new file mode 100644 index 00000000000..a1fe8fce2a9 --- /dev/null +++ b/rewrite-go/pkg/parser/modgraph/modgraph.go @@ -0,0 +1,285 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package modgraph resolves a Go module's transitive module graph from the +// local module cache, WITHOUT invoking the `go` tool or touching the network. +// +// It exists so the parser/connector can compute the graph once, at ingest +// time, and freeze it into the GoResolutionResult marker — recipes then read +// the graph as pure data and never perform I/O. +// +// What it reads (all plain files under $GOMODCACHE/cache/download): +// +// /@v/.mod each dependency's own go.mod (the edges) +// /@v/.ziphash the h1: module hash (for go.sum) +// +// Limitations (documented, not hidden): +// - The graph is the FULL transitive graph, not the go>=1.17 pruned graph, +// so BuildList may be a superset of `go list -m all` for pruned modules. +// - MVS edges are recorded at the version each module was first reached at; +// this matches `go mod graph` whenever a module appears at a single +// version (the common case), which it does for tidy'd modules. +// - Only version-form `replace` directives in the MAIN module are applied; +// local-path replaces are skipped (marked incomplete). +package modgraph + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" + "golang.org/x/mod/semver" + "golang.org/x/mod/sumdb/dirhash" +) + +// Module is one node of the resolved graph at its selected version. +type Module struct { + Path string + Version string // "" for the main module + GoVersion string // the module's own `go` directive + Main bool + ModuleHash string // h1: zip hash + GoModHash string // h1: go.mod hash +} + +// Edge is a require edge From -> To. +type Edge struct { + FromPath string + FromVersion string + ToPath string + ToVersion string + Indirect bool +} + +// Result is the resolved graph. +type Result struct { + BuildList []Module + Graph []Edge + Complete bool // false if any dependency metadata was missing/unreadable +} + +// Resolve builds the module graph and (pruned) build list for the main module +// described by the raw go.mod content, fetching each dependency's go.mod from +// the given ModSource (local cache, GOPROXY, or a tiered combination). It +// performs no process execution. +// +// The traversal mirrors the go>=1.17 pruned module graph +// (cmd/go/internal/modload/buildlist.go:readModGraph): every loaded module's +// requirements become build-list NODES, but a module's requirements are only +// RECURSED into when that module is unpruned (its go directive is < 1.17). The +// resulting build list matches `go list -m all`. +func Resolve(mainGoMod []byte, src ModSource) (Result, error) { + mf, err := modfile.Parse("go.mod", mainGoMod, nil) + if err != nil { + return Result{}, fmt.Errorf("parse main go.mod: %w", err) + } + + res := Result{Complete: true} + + // Main-module version replacements, keyed by "path" and "path@version". + replace := map[string]module.Version{} + for _, r := range mf.Replace { + if r.New.Version == "" { // local filesystem replace — can't resolve from a module source + res.Complete = false + continue + } + replace[r.Old.Path] = r.New + replace[r.Old.Path+"@"+r.Old.Version] = r.New + } + applyReplace := func(m module.Version) module.Version { + if nv, ok := replace[m.Path+"@"+m.Version]; ok { + return nv + } + if nv, ok := replace[m.Path]; ok { + return nv + } + return m + } + + mainPath := "" + mainGo := "" + if mf.Module != nil { + mainPath = mf.Module.Mod.Path + } + if mf.Go != nil { + mainGo = mf.Go.Version + } + + present := map[string]string{} // path -> MVS-selected version (build-list nodes) + goVersionAt := map[string]string{} // path@version -> go directive + goModBytesAt := map[string][]byte{} // path@version -> go.mod bytes (loaded modules) + loadPath := map[string]bool{} // paths we recurse into + + type pv struct{ path, version string } + var loadQueue []pv + enqueued := map[string]bool{} + enqueueLoad := func(m module.Version) { + k := m.Path + "@" + m.Version + if !enqueued[k] { + enqueued[k] = true + loadQueue = append(loadQueue, pv{m.Path, m.Version}) + } + } + // setNode records a build-list node at its highest seen version. If the + // version of a load-path is raised, re-enqueue it (simple iterative MVS). + setNode := func(m module.Version) { + if v, ok := present[m.Path]; !ok || semver.Compare(m.Version, v) > 0 { + present[m.Path] = m.Version + if loadPath[m.Path] { + enqueueLoad(m) + } + } + } + markLoad := func(m module.Version) { + loadPath[m.Path] = true + enqueueLoad(m) + } + + // Roots: the main module's requirements. + for _, r := range mf.Require { + to := applyReplace(r.Mod) + res.Graph = append(res.Graph, Edge{FromPath: mainPath, FromVersion: "", ToPath: to.Path, ToVersion: to.Version, Indirect: r.Indirect}) + setNode(to) + markLoad(to) + } + + for len(loadQueue) > 0 { + cur := loadQueue[0] + loadQueue = loadQueue[1:] + if present[cur.path] != cur.version { + continue // superseded by a higher selected version + } + key := cur.path + "@" + cur.version + b, ok := src.GoMod(cur.path, cur.version) + if !ok { + res.Complete = false + continue + } + df, err := modfile.Parse(key, b, nil) + if err != nil { + res.Complete = false + continue + } + goModBytesAt[key] = b + goV := "" + if df.Go != nil { + goV = df.Go.Version + } + goVersionAt[key] = goV + unpruned := goUnpruned(goV) + for _, r := range df.Require { + to := applyReplace(r.Mod) + res.Graph = append(res.Graph, Edge{FromPath: cur.path, FromVersion: cur.version, ToPath: to.Path, ToVersion: to.Version, Indirect: r.Indirect}) + setNode(to) // every requirement of a loaded module is a build-list node + if unpruned { + markLoad(to) // recurse only through unpruned (go<1.17) modules + } + } + } + + // Assemble the build list. Each module carries a GoModHash (go.sum records + // a go.mod hash for the whole build list); ModuleHash (the zip hash) is set + // only when the source can provide it without downloading the zip. + res.BuildList = append(res.BuildList, Module{Path: mainPath, Version: "", GoVersion: mainGo, Main: true}) + for path, version := range present { + key := path + "@" + version + m := Module{Path: path, Version: version, GoVersion: goVersionAt[key]} + b, ok := goModBytesAt[key] + if !ok { + // Leaf node (not recursed into): fetch its go.mod for the go + // directive and hash. Best-effort — its absence does not change + // build-list membership. + b, ok = src.GoMod(path, version) + if ok { + if df, e := modfile.Parse(key, b, nil); e == nil && df.Go != nil { + m.GoVersion = df.Go.Version + } + } + } + if ok { + if h, err := goModHashBytes(b, path, version); err == nil { + m.GoModHash = h + } + } + if h, has := src.ZipHash(path, version); has { + m.ModuleHash = h + } + res.BuildList = append(res.BuildList, m) + } + return res, nil +} + +// goUnpruned reports whether a module with the given go directive is UNPRUNED +// (go < 1.17), meaning its transitive requirements are part of the module graph. +// An empty/invalid version is treated as unpruned (pre-1.16 behavior). +func goUnpruned(v string) bool { + if v == "" { + return true + } + parts := strings.SplitN(v, ".", 3) + if len(parts) < 2 { + return true + } + maj, err1 := strconv.Atoi(parts[0]) + min, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil { + return true + } + return maj < 1 || (maj == 1 && min < 17) +} + +// readCacheFile reads $download//@v/. +func readCacheFile(download, path, version, suffix string) ([]byte, error) { + ep, err := module.EscapePath(path) + if err != nil { + return nil, err + } + ev, err := module.EscapeVersion(version) + if err != nil { + return nil, err + } + return os.ReadFile(filepath.Join(download, ep, "@v", ev+suffix)) +} + +// readZipHash returns the h1: module hash recorded in the cache `.ziphash`. +func readZipHash(download, path, version string) (string, error) { + b, err := readCacheFile(download, path, version, ".ziphash") + if err != nil { + return "", err + } + h := string(b) + for len(h) > 0 && (h[len(h)-1] == '\n' || h[len(h)-1] == '\r') { + h = h[:len(h)-1] + } + return h, nil +} + +// goModHash computes the h1: hash of a module's go.mod, matching the value go +// records in go.sum (dirhash.Hash1 over the single "@/go.mod"). +// goModHashBytes computes the h1: hash of go.mod content, matching the value go +// records in go.sum (dirhash.Hash1 over the single "@/go.mod"). +func goModHashBytes(b []byte, path, version string) (string, error) { + name := path + "@" + version + "/go.mod" + return dirhash.Hash1([]string{name}, func(string) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(b)), nil + }) +} diff --git a/rewrite-go/pkg/parser/modgraph/modgraph_test.go b/rewrite-go/pkg/parser/modgraph/modgraph_test.go new file mode 100644 index 00000000000..273ef885cfb --- /dev/null +++ b/rewrite-go/pkg/parser/modgraph/modgraph_test.go @@ -0,0 +1,424 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package modgraph + +import ( + goparser "go/parser" + "go/token" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + + "golang.org/x/mod/modfile" +) + +// TestResolveViaProxy validates that the resolver computes the same build list +// when fetching every dependency go.mod from a real GOPROXY (no module cache +// reads) as the toolchain's own `go list -m all`. This is the production path: +// the proxy fetch is injected (here a direct HTTP client; in the CLI it routes +// through OpenRewrite's HttpSender over RPC). +func TestResolveViaProxy(t *testing.T) { + if testing.Short() { + t.Skip("needs network + the go toolchain") + } + dir := t.TempDir() + write(t, dir, "go.mod", "module example.com/proxytest\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n") + write(t, dir, "main.go", "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n") + runGo(t, dir, "mod", "tidy") + golden := listModVersions(t, dir) + + mainGoMod, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + t.Fatal(err) + } + + httpGet := func(url string) ([]byte, int, error) { + resp, err := http.Get(url) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return b, resp.StatusCode, nil + } + src := ProxySource(strings.TrimSpace(runGo(t, dir, "env", "GOPROXY")), httpGet) + + res, err := Resolve(mainGoMod, src) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !res.Complete { + t.Errorf("expected complete proxy resolution; got Complete=false") + } + + ours := map[string]string{} + for _, m := range res.BuildList { + if !m.Main { + ours[m.Path] = m.Version + } + } + for path, gver := range golden { + if path == "example.com/proxytest" { + continue + } + if over, ok := ours[path]; !ok { + t.Errorf("proxy build list missing module %s (golden %s)", path, gver) + } else if over != gver { + t.Errorf("version mismatch for %s: proxy=%s, go list=%s", path, over, gver) + } + } + for path := range ours { + if _, ok := golden[path]; !ok { + t.Errorf("proxy build list has extra module %s (not in `go list -m all`)", path) + } + } + if !t.Failed() { + t.Logf("OK: proxy-fetched build list (%d modules) matches `go list -m all`", len(ours)) + } +} + +// TestNeededViaProxy is the capstone: with NO module cache, the resolver fetches +// every dependency go.mod AND every needed package's source zip from the real +// GOPROXY, and computes the same direct/indirect require set as `go mod tidy`. +// This is the "clean clone, no go tooling" path the CLI needs. +func TestNeededViaProxy(t *testing.T) { + if testing.Short() { + t.Skip("needs network + the go toolchain") + } + dir := t.TempDir() + write(t, dir, "go.mod", "module example.com/needproxy\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n") + write(t, dir, "main.go", "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n") + runGo(t, dir, "mod", "tidy") + wantDirect, wantIndirect := goldenRequires(t, dir) + mainImports := scanMainImports(t, dir) + + mainGoMod, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + t.Fatal(err) + } + httpGet := func(url string) ([]byte, int, error) { + resp, err := http.Get(url) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return b, resp.StatusCode, nil + } + src := ProxySource(strings.TrimSpace(runGo(t, dir, "env", "GOPROXY")), httpGet) + + res, err := Resolve(mainGoMod, src) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + rs := NeededModules(mainImports, "example.com/needproxy", res, src, true) + if !rs.Complete { + t.Errorf("expected complete proxy require-set resolution; got Complete=false") + } + if d := diffSet(wantDirect, keys(rs.Direct)); d != "" { + t.Errorf("direct require set mismatch (proxy):\n%s", d) + } + if d := diffSet(wantIndirect, keys(rs.Indirect)); d != "" { + t.Errorf("indirect require set mismatch (proxy):\n%s", d) + } + if !t.Failed() { + t.Logf("OK: proxy-only require set direct=%v indirect=%v matches go mod tidy", keys(rs.Direct), keys(rs.Indirect)) + } +} + +// TestResolveMatchesToolchain validates the pure-Go resolver against the real +// `go` toolchain: the resolver's selected versions must agree with +// `go list -m all`, and every edge `go mod graph` reports must be present in +// the resolver's graph. The toolchain is used only as the GOLDEN here — the +// resolver itself never execs or hits the network. +func TestResolveMatchesToolchain(t *testing.T) { + if testing.Short() { + t.Skip("needs the go toolchain + module cache/network") + } + dir := t.TempDir() + write(t, dir, "go.mod", "module example.com/graphtest\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n") + write(t, dir, "main.go", "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n") + + // Populate the cache + go.sum so `go list -m all` works. + runGo(t, dir, "mod", "tidy") + + gomodcache := strings.TrimSpace(runGo(t, dir, "env", "GOMODCACHE")) + mainGoMod, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + t.Fatal(err) + } + + res, err := Resolve(mainGoMod, CacheSource(gomodcache)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !res.Complete { + t.Errorf("expected complete resolution from a populated cache; got Complete=false") + } + + // 1. Concrete known edge: pyroscope-go pulls klauspost/compress as indirect. + if !hasEdge(res, "github.com/grafana/pyroscope-go", "v1.2.8", "github.com/klauspost/compress", "v1.17.8", true) { + t.Errorf("missing expected edge pyroscope-go@v1.2.8 -> klauspost/compress@v1.17.8 (indirect)") + } + + // 2. Build-list versions must agree with `go list -m all`. + golden := listModVersions(t, dir) + ours := map[string]string{} + for _, m := range res.BuildList { + ours[m.Path] = m.Version + } + for path, gver := range golden { + if path == "example.com/graphtest" { + continue + } + if over, ok := ours[path]; !ok { + t.Errorf("build list missing module %s (golden %s)", path, gver) + } else if over != gver { + t.Errorf("version mismatch for %s: resolver=%s, go list=%s", path, over, gver) + } + } + + // 3. Every edge `go mod graph` reports must be in our graph. + ourEdges := edgeSet(res) + var missing int + for _, g := range graphEdges(t, dir) { + if !ourEdges[g] { + missing++ + if missing <= 10 { + t.Errorf("edge from `go mod graph` not in resolver graph: %s", g) + } + } + } + if missing == 0 { + t.Logf("OK: build list (%d modules) and all `go mod graph` edges reproduced", len(res.BuildList)) + } + + // 4. go.sum material: every build-list module has a go.mod hash; modules + // whose packages are imported (zip downloaded) also have a zip hash. This + // mirrors go.sum, which records a /go.mod hash for the whole build list + // and an h1: zip hash only for modules that are actually built. + for _, m := range res.BuildList { + if m.Main { + continue + } + if !strings.HasPrefix(m.GoModHash, "h1:") { + t.Errorf("module %s@%s missing h1 GoModHash", m.Path, m.Version) + } + if m.ModuleHash != "" && !strings.HasPrefix(m.ModuleHash, "h1:") { + t.Errorf("module %s@%s has malformed ModuleHash %q", m.Path, m.Version, m.ModuleHash) + } + } + // The directly-imported modules must have a zip hash. + for _, p := range []string{"github.com/grafana/pyroscope-go", "golang.org/x/mod"} { + found := false + for _, m := range res.BuildList { + if m.Path == p && strings.HasPrefix(m.ModuleHash, "h1:") { + found = true + } + } + if !found { + t.Errorf("imported module %s should have a zip ModuleHash", p) + } + } + + // 5. The package-import-graph require set must match real `go mod tidy`'s + // go.mod requires exactly (paths + direct/indirect classification). + mainImports := scanMainImports(t, dir) + rs := NeededModules(mainImports, "example.com/graphtest", res, CacheSource(gomodcache), true) + if !rs.Complete { + t.Errorf("expected complete require-set resolution; got Complete=false") + } + wantDirect, wantIndirect := goldenRequires(t, dir) + if d := diffSet(wantDirect, keys(rs.Direct)); d != "" { + t.Errorf("direct require set mismatch:\n%s", d) + } + if d := diffSet(wantIndirect, keys(rs.Indirect)); d != "" { + t.Errorf("indirect require set mismatch:\n%s", d) + } + if t.Failed() { + t.Logf("resolver direct=%v indirect=%v", keys(rs.Direct), keys(rs.Indirect)) + } +} + +// scanMainImports returns the union of import paths across all .go files in +// the main module (mirrors what the recipe scan phase collects). +func scanMainImports(t *testing.T, dir string) []string { + t.Helper() + set := map[string]bool{} + fset := token.NewFileSet() + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") { + return err + } + f, perr := goparser.ParseFile(fset, path, nil, goparser.ImportsOnly) + if perr != nil { + return nil + } + for _, spec := range f.Imports { + set[strings.Trim(spec.Path.Value, "\"`")] = true + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return keys(setToMap(set)) +} + +// goldenRequires parses the real (post-tidy) go.mod and returns the direct and +// indirect module paths it declares. +func goldenRequires(t *testing.T, dir string) (direct, indirect []string) { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + t.Fatal(err) + } + mf, err := modfile.Parse("go.mod", b, nil) + if err != nil { + t.Fatal(err) + } + for _, r := range mf.Require { + if r.Indirect { + indirect = append(indirect, r.Mod.Path) + } else { + direct = append(direct, r.Mod.Path) + } + } + return direct, indirect +} + +func keys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func setToMap(s map[string]bool) map[string]bool { return s } + +func diffSet(want, got []string) string { + w := map[string]bool{} + for _, x := range want { + w[x] = true + } + g := map[string]bool{} + for _, x := range got { + g[x] = true + } + var msg []string + for _, x := range want { + if !g[x] { + msg = append(msg, " missing (tidy has, resolver lacks): "+x) + } + } + for _, x := range got { + if !w[x] { + msg = append(msg, " extra (resolver has, tidy lacks): "+x) + } + } + return strings.Join(msg, "\n") +} + +func hasEdge(res Result, fp, fv, tp, tv string, indirect bool) bool { + for _, e := range res.Graph { + if e.FromPath == fp && e.FromVersion == fv && e.ToPath == tp && e.ToVersion == tv && e.Indirect == indirect { + return true + } + } + return false +} + +// edgeSet renders edges in `go mod graph` textual form ("from to", where a +// version-less node is the main module). +func edgeSet(res Result) map[string]bool { + s := map[string]bool{} + for _, e := range res.Graph { + s[node(e.FromPath, e.FromVersion)+" "+node(e.ToPath, e.ToVersion)] = true + } + return s +} + +func node(path, version string) string { + if version == "" { + return path + } + return path + "@" + version +} + +func listModVersions(t *testing.T, dir string) map[string]string { + out := runGo(t, dir, "list", "-m", "-f", "{{.Path}} {{.Version}}", "all") + m := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + f := strings.Fields(line) + if len(f) == 2 { + m[f[0]] = f[1] + } else if len(f) == 1 { + m[f[0]] = "" + } + } + return m +} + +func graphEdges(t *testing.T, dir string) []string { + out := runGo(t, dir, "mod", "graph") + var edges []string + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if line == "" { + continue + } + // `go mod graph` emits synthetic `go@x` / `toolchain@x` pseudo-nodes + // for the go-directive requirement; these are not modules. + if f := strings.Fields(line); len(f) == 2 { + if isPseudoNode(f[0]) || isPseudoNode(f[1]) { + continue + } + } + edges = append(edges, line) + } + return edges +} + +func isPseudoNode(n string) bool { + return n == "go" || n == "toolchain" || + strings.HasPrefix(n, "go@") || strings.HasPrefix(n, "toolchain@") +} + +func runGo(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("go", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +func write(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/rewrite-go/pkg/parser/modgraph/needed.go b/rewrite-go/pkg/parser/modgraph/needed.go new file mode 100644 index 00000000000..ff38c81aa1b --- /dev/null +++ b/rewrite-go/pkg/parser/modgraph/needed.go @@ -0,0 +1,210 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package modgraph + +import ( + "errors" + goparser "go/parser" + "go/token" + "strings" +) + +var errPackageNotFound = errors.New("package source not found") + +// RequireSet is the set of modules `go mod tidy` would write to go.mod, +// classified the way tidy classifies them. It is computed from the package +// import graph, not just the module graph, so it matches go.mod exactly. +type RequireSet struct { + // Direct maps module path -> version for modules that provide a package + // imported by the main module itself. + Direct map[string]string + // Indirect maps module path -> version for modules needed transitively to + // build the main module's packages, but not imported by it directly. + Indirect map[string]string + // Complete is false if any package directory could not be read (e.g. a + // module was not extracted to the cache), making the set best-effort. + Complete bool + // Unresolved lists import paths that mapped to no build-list module. + Unresolved []string + // MissingDirs lists package directories that could not be read. + MissingDirs []string +} + +// NeededModules computes the exact go.mod require set by walking the package +// import graph. It starts from mainImports — the union of import paths across +// ALL of the main module's .go files (including its tests, which tidy counts) +// — and follows imports into dependency packages, reading their imports from +// the extracted module cache. No `go` execution, no network. +// +// Dependency test files are skipped (tidy does not load dep tests for a +// go>=1.17 main module). Build-constraint-excluded files ARE read: tidy keeps +// modules needed by any GOOS/GOARCH, so all platform files count. +// +// separateIndirect selects the go.mod policy: when true (go >= 1.17) the full +// transitive indirect set is recorded; when false (go < 1.17) indirect modules +// that are implied by another required module's go.mod are omitted, matching +// the smaller pre-1.17 require list. +func NeededModules(mainImports []string, mainModulePath string, res Result, src ModSource, separateIndirect bool) RequireSet { + rs := RequireSet{Direct: map[string]string{}, Indirect: map[string]string{}, Complete: true} + + type modver struct{ path, version string } + var mods []modver + for _, m := range res.BuildList { + if m.Main { + continue + } + mods = append(mods, modver{m.Path, m.Version}) + } + // moduleOf returns the longest build-list module path that is a prefix of + // importPath (the module that provides that package), with its version. + moduleOf := func(importPath string) (string, string) { + best, bestVer := "", "" + for _, m := range mods { + if importPath == m.path || strings.HasPrefix(importPath, m.path+"/") { + if len(m.path) > len(best) { + best, bestVer = m.path, m.version + } + } + } + return best, bestVer + } + isLocal := func(importPath string) bool { + return importPath == mainModulePath || strings.HasPrefix(importPath, mainModulePath+"/") + } + + needed := map[string]string{} + direct := map[string]bool{} + visited := map[string]bool{} + var queue []string + + for _, imp := range mainImports { + if isStdlibImport(imp) || isLocal(imp) { + continue + } + m, ver := moduleOf(imp) + if m == "" { + rs.Complete = false + continue + } + direct[m] = true + needed[m] = ver + queue = append(queue, imp) + } + + for len(queue) > 0 { + imp := queue[0] + queue = queue[1:] + if visited[imp] { + continue + } + visited[imp] = true + if isStdlibImport(imp) || isLocal(imp) { + continue + } + m, ver := moduleOf(imp) + if m == "" { + // Import maps to no build-list module. Since the build list is + // authoritative (it mirrors `go list -m all`), this means the + // import is not part of the real build — e.g. an `//go:build + // ignore` generator or a platform the build doesn't select. It is + // not a missing dependency, so record it for diagnostics but do + // not treat resolution as incomplete. + rs.Unresolved = append(rs.Unresolved, imp) + continue + } + needed[m] = ver + + deps, err := packageImports(src, m, ver, imp) + if err != nil { + rs.Complete = false + rs.MissingDirs = append(rs.MissingDirs, imp) + continue + } + for _, dep := range deps { + if isStdlibImport(dep) || isLocal(dep) || visited[dep] { + continue + } + queue = append(queue, dep) + } + } + + // For go < 1.17, omit indirect modules that are implied by another needed + // module's go.mod (their version is already pinned by the graph). go >= 1.17 + // records the full set. + implied := map[string]bool{} + if !separateIndirect { + for _, e := range res.Graph { + if e.FromPath != "" && e.FromPath != mainModulePath && needed[e.FromPath] != "" { + implied[e.ToPath] = true + } + } + } + + for mod, ver := range needed { + switch { + case direct[mod]: + rs.Direct[mod] = ver + case implied[mod]: + // omitted (pre-1.17 implied indirect) + default: + rs.Indirect[mod] = ver + } + } + return rs +} + +// packageImports returns the non-test imports of the package at importPath, +// which lives in module mod@version. Source files come from the ModSource (the +// local cache or a downloaded+extracted module zip). +func packageImports(src ModSource, mod, version, importPath string) ([]string, error) { + files, ok := src.PackageGoFiles(mod, version, importPath) + if !ok { + return nil, errPackageNotFound + } + set := map[string]bool{} + fset := token.NewFileSet() + for name, content := range files { + if strings.HasSuffix(name, "_test.go") { + continue + } + f, err := goparser.ParseFile(fset, name, content, goparser.ImportsOnly) + if err != nil { + continue + } + for _, spec := range f.Imports { + p := strings.Trim(spec.Path.Value, "\"`") + if p != "" { + set[p] = true + } + } + } + out := make([]string, 0, len(set)) + for p := range set { + out = append(out, p) + } + return out, nil +} + +// isStdlibImport reports whether importPath is a standard-library package +// (no dot in its first path segment). Mirrors gofmt/goimports' heuristic. +func isStdlibImport(importPath string) bool { + first := importPath + if i := strings.IndexByte(importPath, '/'); i >= 0 { + first = importPath[:i] + } + return !strings.Contains(first, ".") +} diff --git a/rewrite-go/pkg/parser/modgraph/source.go b/rewrite-go/pkg/parser/modgraph/source.go new file mode 100644 index 00000000000..2f9ada5f747 --- /dev/null +++ b/rewrite-go/pkg/parser/modgraph/source.go @@ -0,0 +1,288 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package modgraph + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "strings" + "sync" + + "golang.org/x/mod/module" +) + +// ModSource supplies module metadata (go.mod files, and where available the +// zip hash) for the resolver. It abstracts WHERE the data comes from so the +// resolution algorithm is identical whether the bytes are read from the local +// module cache or fetched from a GOPROXY. +// +// The proxy implementation deliberately does not perform HTTP itself: it calls +// an injected getter, so in production the bytes are fetched through the CLI's +// OpenRewrite HttpSender (via bidirectional RPC), and in tests through a direct +// HTTP client or a fake. +type ModSource interface { + // GoMod returns the go.mod bytes for module path@version, and whether it + // was found. + GoMod(path, version string) ([]byte, bool) + // ZipHash returns the h1: module (zip) hash, and whether it is available. + // Only the local cache can provide this without downloading the zip. + ZipHash(path, version string) (string, bool) + // PackageGoFiles returns the .go source files (keyed by base filename, + // including tests) of the package at importPath within module + // modPath@version. ok is false if the package could not be located. This is + // what gives a clean clone the dependency SOURCES needed to compute the + // package-import graph (and, in future, full type attribution) without any + // Go tooling — the proxy implementation downloads and extracts the module + // zip on demand. + PackageGoFiles(modPath, version, importPath string) (files map[string][]byte, ok bool) +} + +// HTTPGet performs an HTTP GET and returns the body, status code, and error. +// In production this is backed by the CLI's HttpSender over RPC. +type HTTPGet func(url string) (body []byte, status int, err error) + +// CacheSource reads module metadata from a local module cache (the value of +// `go env GOMODCACHE`). +func CacheSource(gomodcache string) ModSource { + return &cacheSource{root: gomodcache, download: filepath.Join(gomodcache, "cache", "download")} +} + +type cacheSource struct { + root string // GOMODCACHE; extracted module dirs live directly under it + download string // GOMODCACHE/cache/download +} + +func (c *cacheSource) GoMod(path, version string) ([]byte, bool) { + b, err := readCacheFile(c.download, path, version, ".mod") + if err != nil { + return nil, false + } + return b, true +} + +func (c *cacheSource) ZipHash(path, version string) (string, bool) { + h, err := readZipHash(c.download, path, version) + if err != nil { + return "", false + } + return h, true +} + +func (c *cacheSource) PackageGoFiles(modPath, version, importPath string) (map[string][]byte, bool) { + ep, err := module.EscapePath(modPath) + if err != nil { + return nil, false + } + ev, err := module.EscapeVersion(version) + if err != nil { + return nil, false + } + rel := strings.TrimPrefix(strings.TrimPrefix(importPath, modPath), "/") + dir := filepath.Join(c.root, ep+"@"+ev, filepath.FromSlash(rel)) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, false + } + files := map[string][]byte{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { + continue + } + if b, err := os.ReadFile(filepath.Join(dir, e.Name())); err == nil { + files[e.Name()] = b + } + } + return files, len(files) > 0 +} + +// ProxySource fetches module metadata from one or more GOPROXY base URLs using +// the injected get function. goproxy is a GOPROXY-style list (comma/pipe +// separated); "off"/"direct"/"none" entries and the VCS fallback are skipped +// (this source only speaks the proxy protocol). If goproxy is empty, +// https://proxy.golang.org is used. +func ProxySource(goproxy string, get HTTPGet) ModSource { + var bases []string + for _, p := range strings.FieldsFunc(goproxy, func(r rune) bool { return r == ',' || r == '|' }) { + p = strings.TrimSpace(p) + if p == "" || p == "off" || p == "direct" || p == "none" { + continue + } + bases = append(bases, strings.TrimRight(p, "/")) + } + if len(bases) == 0 { + bases = []string{"https://proxy.golang.org"} + } + return &proxySource{bases: bases, get: get, zips: map[string]map[string][]byte{}} +} + +type proxySource struct { + bases []string + get HTTPGet + mu sync.Mutex + // zips caches the extracted contents of a module zip, keyed by + // "modPath@version" -> (full zip entry path -> bytes). A nil value records + // a failed download so it is not retried. + zips map[string]map[string][]byte +} + +func (p *proxySource) GoMod(path, version string) ([]byte, bool) { + ep, err := module.EscapePath(path) + if err != nil { + return nil, false + } + ev, err := module.EscapeVersion(version) + if err != nil { + return nil, false + } + suffix := "/" + ep + "/@v/" + ev + ".mod" + for _, base := range p.bases { + body, status, err := p.get(base + suffix) + if err == nil && status == 200 && len(body) > 0 { + return body, true + } + } + return nil, false +} + +// ZipHash is unavailable from the proxy without downloading and hashing the +// module zip; go.sum generation is handled separately. +func (p *proxySource) ZipHash(path, version string) (string, bool) { + return "", false +} + +func (p *proxySource) PackageGoFiles(modPath, version, importPath string) (map[string][]byte, bool) { + entries, ok := p.moduleZip(modPath, version) + if !ok { + return nil, false + } + rel := strings.TrimPrefix(strings.TrimPrefix(importPath, modPath), "/") + // Package files live directly under "@//" — the + // package is exactly one directory level (no deeper recursion). + prefix := modPath + "@" + version + "/" + if rel != "" { + prefix += rel + "/" + } + files := map[string][]byte{} + for name, content := range entries { + if !strings.HasPrefix(name, prefix) { + continue + } + tail := name[len(prefix):] + if strings.Contains(tail, "/") || !strings.HasSuffix(tail, ".go") { + continue // a deeper subpackage or non-go file + } + files[tail] = content + } + return files, len(files) > 0 +} + +// moduleZip downloads (once) and extracts the module zip for modPath@version, +// returning a map of zip-entry path -> contents. +func (p *proxySource) moduleZip(modPath, version string) (map[string][]byte, bool) { + key := modPath + "@" + version + p.mu.Lock() + if cached, seen := p.zips[key]; seen { + p.mu.Unlock() + return cached, cached != nil + } + p.mu.Unlock() + + entries := p.downloadZip(modPath, version) + p.mu.Lock() + p.zips[key] = entries // nil on failure — caches the negative result + p.mu.Unlock() + return entries, entries != nil +} + +func (p *proxySource) downloadZip(modPath, version string) map[string][]byte { + ep, err := module.EscapePath(modPath) + if err != nil { + return nil + } + ev, err := module.EscapeVersion(version) + if err != nil { + return nil + } + suffix := "/" + ep + "/@v/" + ev + ".zip" + var raw []byte + for _, base := range p.bases { + body, status, err := p.get(base + suffix) + if err == nil && status == 200 && len(body) > 0 { + raw = body + break + } + } + if raw == nil { + return nil + } + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + return nil + } + entries := map[string][]byte{} + for _, f := range zr.File { + if f.FileInfo().IsDir() || !strings.HasSuffix(f.Name, ".go") { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + var buf bytes.Buffer + _, _ = buf.ReadFrom(rc) + rc.Close() + entries[f.Name] = buf.Bytes() + } + return entries +} + +// TieredSource tries each source in order, returning the first hit. Use it to +// prefer the local cache and fall back to the proxy. +func TieredSource(sources ...ModSource) ModSource { + return &tieredSource{sources: sources} +} + +type tieredSource struct{ sources []ModSource } + +func (t *tieredSource) GoMod(path, version string) ([]byte, bool) { + for _, s := range t.sources { + if b, ok := s.GoMod(path, version); ok { + return b, true + } + } + return nil, false +} + +func (t *tieredSource) ZipHash(path, version string) (string, bool) { + for _, s := range t.sources { + if h, ok := s.ZipHash(path, version); ok { + return h, true + } + } + return "", false +} + +func (t *tieredSource) PackageGoFiles(modPath, version, importPath string) (map[string][]byte, bool) { + for _, s := range t.sources { + if files, ok := s.PackageGoFiles(modPath, version, importPath); ok { + return files, true + } + } + return nil, false +} diff --git a/rewrite-go/pkg/recipe/golang/activate.go b/rewrite-go/pkg/recipe/golang/activate.go index b12ecb220fd..176a3d37603 100644 --- a/rewrite-go/pkg/recipe/golang/activate.go +++ b/rewrite-go/pkg/recipe/golang/activate.go @@ -33,4 +33,5 @@ func Activate(r *recipe.Registry) { r.Register(&RemoveUnusedImports{}, golangCategory) r.Register(&OrderImports{}, golangCategory) r.Register(&RenamePackage{}, golangCategory) + r.Register(&GoModTidy{}, golangCategory) } diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go new file mode 100644 index 00000000000..9e1ca434e15 --- /dev/null +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go @@ -0,0 +1,491 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package golang + +import ( + "sort" + "strconv" + "strings" + + "github.com/google/uuid" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" + "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" + "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe/golang/internal" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" + "github.com/openrewrite/rewrite/rewrite-go/pkg/visitor" +) + +// GoModTidy emulates the go.mod-affecting behavior of `go mod tidy` that is +// decidable from the parsed LST alone (no module graph / network access): +// +// - Re-marks each `require` entry as direct (no comment) or `// indirect` +// based on whether the module is imported by any .go file in the module. +// - Sorts entries within each `require` block lexicographically by module +// path (then version), the same key the go toolchain uses. +// +// It does NOT add missing requires, recompute go.sum, remove provably-unused +// indirect requires, or perform MVS version selection — those require the +// module graph and are out of scope for this LST-only phase. +// +// GoModTidy is a ScanningRecipe: the scan phase collects the set of imported +// module paths across every .go file in the project, then the edit phase +// rewrites the sibling go.mod. +type GoModTidy struct { + recipe.ScanningBase +} + +func (r *GoModTidy) Name() string { return "org.openrewrite.golang.GoModTidy" } +func (r *GoModTidy) DisplayName() string { return "Tidy go.mod (LST-only)" } +func (r *GoModTidy) Description() string { + return "Emulate the subset of `go mod tidy` that is decidable from source alone: re-mark `// indirect` " + + "requires from the import graph and sort `require` blocks. Does not add missing requires, " + + "recompute go.sum, or remove provably-unused indirect requires." +} + +// tidyAcc is the accumulator threaded across the scan phase. +type tidyAcc struct { + // modulePath is the main module's path (from the `module` directive). + modulePath string + // rawImports is every import path seen across the project's .go files. + rawImports map[string]bool + // requireMods is the set of module paths declared in `require`. + requireMods map[string]bool +} + +func (r *GoModTidy) InitialValue(ctx *recipe.ExecutionContext) any { + return &tidyAcc{ + rawImports: map[string]bool{}, + requireMods: map[string]bool{}, + } +} + +func (r *GoModTidy) Scanner(acc any) recipe.TreeVisitor { + return visitor.Init(&goModTidyScanner{acc: acc.(*tidyAcc)}) +} + +func (r *GoModTidy) EditorWithData(acc any) recipe.TreeVisitor { + return visitor.Init(&goModTidyEditor{acc: acc.(*tidyAcc)}) +} + +// --- scan phase --- +// +// NOTE: `go mod tidy` unions imports across ALL build configurations +// (every GOOS/GOARCH and build tag). This scanner only sees the imports of +// the .go files present in the LST it is given. If the project was parsed +// under a single build context (the default), platform-gated imports — e.g. +// a windows-only dependency in a `//go:build windows` file — will be absent +// and such modules can be misclassified as indirect. Full parity therefore +// requires the project to be parsed so that all-platform files are included. + +type goModTidyScanner struct { + visitor.GoVisitor + acc *tidyAcc +} + +func (v *goModTidyScanner) VisitCompilationUnit(cu *golang.CompilationUnit, p any) java.J { + if cu.Imports != nil { + for _, rp := range cu.Imports.Elements { + if path := internal.ImportPath(rp.Element); path != "" { + v.acc.rawImports[path] = true + } + } + } + return v.GoVisitor.VisitCompilationUnit(cu, p) +} + +func (v *goModTidyScanner) VisitGoModDirective(d *golang.GoModDirective, p any) java.Tree { + switch d.Keyword { + case "module": + if len(d.Values) > 0 { + v.acc.modulePath = d.Values[0].Text + } + case "require": + if len(d.Values) > 0 { + v.acc.requireMods[d.Values[0].Text] = true + } + case "": + // Block entry line: a require entry inside a `require ( … )` block + // has an empty keyword. Record its module path. + if len(d.Values) > 0 && looksLikeModulePath(d.Values[0].Text) { + v.acc.requireMods[d.Values[0].Text] = true + } + } + return v.GoVisitor.VisitGoModDirective(d, p) +} + +// --- edit phase --- + +type goModTidyEditor struct { + visitor.GoVisitor + acc *tidyAcc +} + +// reqEntry is a single collected require entry during the rebuild. +type reqEntry struct { + path string + version string + indirect bool +} + +func (v *goModTidyEditor) VisitGoMod(gm *golang.GoMod, p any) java.Tree { + gm = v.GoVisitor.VisitGoMod(gm, p).(*golang.GoMod) + + separateIndirect := goVersionAtLeast(gm, 1, 17) + + // When the go.mod carries a fully-resolved GoResolutionResult, its Requires + // list is the authoritative require set the package-import graph computed at + // parse time (already pruned of unused modules, with missing ones added and + // // indirect classified). Otherwise fall back to the LST-only Phase 1: + // re-mark and sort the existing requires from the import scan. + res := findResolution(gm) + authoritative := res != nil && res.GraphComplete && len(res.Requires) > 0 + + // Strip the existing require statements, remembering where the first one + // was and its leading whitespace. + firstIdx := -1 + var firstPrefix java.Space + var collected []reqEntry + kept := make([]java.RightPadded[golang.GoModStatement], 0, len(gm.Statements)) + for _, st := range gm.Statements { + switch s := st.Element.(type) { + case *golang.GoModBlock: + if s.Keyword == "require" { + if firstIdx == -1 { + firstIdx = len(kept) + firstPrefix = s.Prefix + } + for _, e := range s.Entries { + if path, version := moduleOf(e.Element); path != "" { + collected = append(collected, reqEntry{path, version, false}) + } + } + continue + } + case *golang.GoModDirective: + if s.Keyword == "require" && len(s.Values) > 0 { + if firstIdx == -1 { + firstIdx = len(kept) + firstPrefix = s.Prefix + } + version := "" + if len(s.Values) > 1 { + version = s.Values[1].Text + } + collected = append(collected, reqEntry{s.Values[0].Text, version, false}) + continue + } + } + kept = append(kept, st) + } + + // Determine the entries to emit, in priority order: + // 1. Compute the tidy require set NOW from the parse-time-resolved module + // graph + the scanned imports + a ModSource (cache/proxy). This is the + // production path: the marker carries the declared requires and the + // resolved graph, and the precise set is computed at recipe time. + // 2. Use a pre-computed authoritative require set on the marker (tests). + // 3. LST-only Phase 1: re-mark and sort the declared requires. + var entries []reqEntry + if computed, ok := v.computeTidySet(gm, res, separateIndirect, p); ok { + entries = computed + } else if authoritative { + for _, r := range res.Requires { + entries = append(entries, reqEntry{r.ModulePath, r.Version, r.Indirect}) + } + } else { + direct := v.directModules() + for _, e := range collected { + entries = append(entries, reqEntry{e.path, e.version, !direct[e.path]}) + } + } + + if len(entries) == 0 { + if firstIdx == -1 { + return gm // nothing to do + } + return gm.WithStatements(kept) // requires all pruned away + } + if firstIdx == -1 { + // No existing require statement to anchor on (e.g. all were missing + // and the graph added them); insert after the header directives. + firstIdx = headerInsertIndex(kept) + firstPrefix = java.Space{Whitespace: "\n"} + } + + // Partition into the require statements the toolchain would emit: for + // go >= 1.17, a direct-only statement followed by an indirect-only one; + // otherwise a single mixed statement. Each group is sorted by path. + var groups [][]reqEntry + if separateIndirect { + var directGroup, indirectGroup []reqEntry + for _, e := range entries { + if e.indirect { + indirectGroup = append(indirectGroup, e) + } else { + directGroup = append(directGroup, e) + } + } + if len(directGroup) > 0 { + groups = append(groups, directGroup) + } + if len(indirectGroup) > 0 { + groups = append(groups, indirectGroup) + } + } else { + groups = append(groups, entries) + } + + reqStmts := make([]java.RightPadded[golang.GoModStatement], 0, len(groups)) + for i, g := range groups { + sortEntries(g) + prefix := java.Space{Whitespace: "\n"} + if i == 0 { + prefix = firstPrefix + } + reqStmts = append(reqStmts, buildRequireStatement(g, prefix)) + } + + final := make([]java.RightPadded[golang.GoModStatement], 0, len(kept)+len(reqStmts)) + final = append(final, kept[:firstIdx]...) + final = append(final, reqStmts...) + final = append(final, kept[firstIdx:]...) + return gm.WithStatements(final) +} + +// sortEntries orders require entries by module path, then version. +func sortEntries(es []reqEntry) { + sort.SliceStable(es, func(i, j int) bool { + if es[i].path != es[j].path { + return es[i].path < es[j].path + } + return es[i].version < es[j].version + }) +} + +// buildRequireStatement renders a sorted group of entries as a single +// require statement: a single-line directive when the group has one entry, +// or a factored `require ( … )` block otherwise. +func buildRequireStatement(group []reqEntry, prefix java.Space) java.RightPadded[golang.GoModStatement] { + if len(group) == 1 { + e := group[0] + d := &golang.GoModDirective{ + Ident: uuid.New(), + Prefix: prefix, + Keyword: "require", + Values: []*golang.GoModValue{ + {Ident: uuid.New(), Prefix: java.SingleSpace, Text: e.path}, + {Ident: uuid.New(), Prefix: java.SingleSpace, Text: e.version}, + }, + } + return java.RightPadded[golang.GoModStatement]{ + Element: d, + After: setIndirectComment(java.Space{}, e.indirect), + } + } + + blockEntries := make([]java.RightPadded[golang.GoModStatement], len(group)) + for i, e := range group { + entryPrefix := java.Space{Whitespace: "\t"} + if i == 0 { + entryPrefix = java.Space{Whitespace: "\n\t"} + } + d := &golang.GoModDirective{ + Ident: uuid.New(), + Prefix: entryPrefix, + Values: []*golang.GoModValue{ + {Ident: uuid.New(), Text: e.path}, + {Ident: uuid.New(), Prefix: java.SingleSpace, Text: e.version}, + }, + } + blockEntries[i] = java.RightPadded[golang.GoModStatement]{ + Element: d, + After: setIndirectComment(java.Space{}, e.indirect), + } + } + blk := &golang.GoModBlock{ + Ident: uuid.New(), + Prefix: prefix, + Keyword: "require", + BeforeLParen: java.SingleSpace, + Entries: blockEntries, + BeforeRParen: java.Space{}, + } + return java.RightPadded[golang.GoModStatement]{ + Element: blk, + After: java.Space{Whitespace: "\n"}, + } +} + +// goVersionAtLeast reports whether the go.mod's `go` directive is >= the +// given major.minor. Absent or unparseable versions are treated as recent +// (the toolchain default), so the separate-indirect-block form is used. +func goVersionAtLeast(gm *golang.GoMod, major, minor int) bool { + for _, st := range gm.Statements { + d, ok := st.Element.(*golang.GoModDirective) + if !ok || d.Keyword != "go" || len(d.Values) == 0 { + continue + } + parts := strings.SplitN(d.Values[0].Text, ".", 3) + if len(parts) < 2 { + return true + } + gMaj, err1 := strconv.Atoi(parts[0]) + gMin, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil { + return true + } + return gMaj > major || (gMaj == major && gMin >= minor) + } + return true +} + +// findResolution returns the GoResolutionResult marker attached to the go.mod, +// or nil if none is present. +func findResolution(gm *golang.GoMod) *golang.GoResolutionResult { + for i := range gm.Markers.Entries { + if r, ok := gm.Markers.Entries[i].(golang.GoResolutionResult); ok { + return &r + } + } + return nil +} + +// headerInsertIndex returns the index just after the last leading header +// directive (module / go / toolchain) in stmts, the natural spot to insert a +// require block when none exists yet. +func headerInsertIndex(stmts []java.RightPadded[golang.GoModStatement]) int { + idx := 0 + for i, st := range stmts { + if d, ok := st.Element.(*golang.GoModDirective); ok { + switch d.Keyword { + case "module", "go", "toolchain": + idx = i + 1 + } + } + } + return idx +} + +// computeTidySet computes the exact go.mod require set at recipe time from the +// parse-time-resolved module graph (carried on the marker), the imports scanned +// from the project's .go files, and the dependency ModSource installed in the +// ExecutionContext (cache + GOPROXY via the CLI HttpSender). Returns ok=false +// when any input is missing, so the caller falls back to the marker's +// authoritative set (tests) or LST-only Phase 1. +func (v *goModTidyEditor) computeTidySet(gm *golang.GoMod, res *golang.GoResolutionResult, separateIndirect bool, p any) ([]reqEntry, bool) { + if res == nil || !res.GraphComplete || len(res.BuildList) == 0 { + return nil, false + } + ctx, ok := p.(*recipe.ExecutionContext) + if !ok { + return nil, false + } + src := ModSourceFrom(ctx) + if src == nil { + return nil, false + } + + mainImports := make([]string, 0, len(v.acc.rawImports)) + for imp := range v.acc.rawImports { + mainImports = append(mainImports, imp) + } + rs := modgraph.NeededModules(mainImports, v.acc.modulePath, modgraph.FromMarker(*res), src, separateIndirect) + if !rs.Complete { + return nil, false + } + + entries := make([]reqEntry, 0, len(rs.Direct)+len(rs.Indirect)) + for mod, ver := range rs.Direct { + entries = append(entries, reqEntry{mod, ver, false}) + } + for mod, ver := range rs.Indirect { + entries = append(entries, reqEntry{mod, ver, true}) + } + return entries, true +} + +// directModules returns the set of required module paths that are directly +// imported by some .go file in the project. +func (v *goModTidyEditor) directModules() map[string]bool { + direct := map[string]bool{} + for imp := range v.acc.rawImports { + if internal.IsStdlib(imp) || internal.IsLocal(imp, v.acc.modulePath) { + continue + } + if m := providingModule(imp, v.acc.requireMods); m != "" { + direct[m] = true + } + } + return direct +} + +// moduleOf returns the (path, version) of a require entry directive. +func moduleOf(st golang.GoModStatement) (string, string) { + d, ok := st.(*golang.GoModDirective) + if !ok || len(d.Values) == 0 { + return "", "" + } + path := d.Values[0].Text + version := "" + if len(d.Values) > 1 { + version = d.Values[1].Text + } + return path, version +} + +// setIndirectComment reconstructs an entry's trailing After so that it +// carries (or omits) a `// indirect` line comment, preserving any other +// trailing comments. The line always ends in a newline. +func setIndirectComment(after java.Space, indirect bool) java.Space { + var others []java.Comment + for _, c := range after.Comments { + if strings.TrimSpace(c.Text) == "// indirect" { + continue + } + others = append(others, c) + } + if indirect { + others = append(others, java.Comment{Kind: java.LineComment, Text: "// indirect", Suffix: "\n"}) + return java.Space{Whitespace: " ", Comments: others} + } + if len(others) > 0 { + return java.Space{Whitespace: " ", Comments: others} + } + return java.Space{Whitespace: "\n"} +} + +// providingModule returns the longest declared module path that is a prefix +// of importPath (the module that provides the imported package), or "". +func providingModule(importPath string, requireMods map[string]bool) string { + best := "" + for m := range requireMods { + if importPath == m || strings.HasPrefix(importPath, m+"/") { + if len(m) > len(best) { + best = m + } + } + } + return best +} + +// looksLikeModulePath is a cheap heuristic to distinguish a require entry's +// module path token from other directive tokens (versions, operators). +func looksLikeModulePath(s string) bool { + return strings.Contains(s, ".") && !strings.HasPrefix(s, "v") && s != "=>" +} diff --git a/rewrite-go/pkg/recipe/golang/module_resolution.go b/rewrite-go/pkg/recipe/golang/module_resolution.go new file mode 100644 index 00000000000..1df45107f87 --- /dev/null +++ b/rewrite-go/pkg/recipe/golang/module_resolution.go @@ -0,0 +1,107 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package golang + +import ( + goparser "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" + "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" + "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" +) + +// modSourceKey is the ExecutionContext key under which the RPC server installs +// the dependency-resolution ModSource (cache + GOPROXY). The proxy tier routes +// HTTP through the CLI's HttpSender, so recipe-time resolution may hit the +// network exactly as parse-time resolution does. +const modSourceKey = "org.openrewrite.golang.modgraph.source" + +// SetModSource installs the ModSource used by recipe-time module-graph +// resolution. Called by the RPC server when it builds the recipe +// ExecutionContext. +func SetModSource(ctx *recipe.ExecutionContext, src modgraph.ModSource) { + ctx.PutMessage(modSourceKey, src) +} + +// ModSourceFrom returns the installed ModSource, or nil when none is present +// (e.g. running outside the CLI, with no network access configured). +func ModSourceFrom(ctx *recipe.ExecutionContext) modgraph.ModSource { + if v, ok := ctx.GetMessage(modSourceKey); ok { + if s, ok := v.(modgraph.ModSource); ok { + return s + } + } + return nil +} + +// RefreshModel recomputes the GoResolutionResult for an (already-edited) go.mod +// and returns the new marker. It re-parses the declared model from the current +// go.mod text (module path, requires, replaces, excludes, retracts, go directive) +// and, when a ModSource is installed in ctx, re-resolves the module graph + build +// list — fetching dependency metadata via that source, which may reach the +// network through the CLI HttpSender. +// +// This is the mechanism by which a recipe that mutates dependencies refreshes the +// model so later recipes in the same run see an accurate view. Mirrors +// rewrite-maven's UpdateMavenModel. The declared `Requires` stay faithful to the +// file; the resolved view rides in BuildList/Graph. +// +// Returns ok=false only if the go.mod cannot be parsed. +func RefreshModel(gm *golang.GoMod, ctx *recipe.ExecutionContext) (golang.GoResolutionResult, bool) { + content := printer.PrintGoMod(gm) + mrr, err := goparser.ParseGoMod("go.mod", content) + if err != nil || mrr == nil { + return golang.GoResolutionResult{}, false + } + // Carry over the go.sum-derived resolutions from the prior marker — a + // dependency edit changes go.mod, not go.sum. + if prev := GetResolutionResult(gm); prev != nil { + mrr.ResolvedDependencies = prev.ResolvedDependencies + } + if src := ModSourceFrom(ctx); src != nil { + if res, e := modgraph.Resolve([]byte(content), src); e == nil { + modgraph.ApplyTo(res, mrr) + } + } + return *mrr, true +} + +// GetResolutionResult returns the current GoResolutionResult marker on a go.mod, +// or nil. Recipes use it to read the resolved model (build list, graph). +func GetResolutionResult(gm *golang.GoMod) *golang.GoResolutionResult { + for i := range gm.Markers.Entries { + if r, ok := gm.Markers.Entries[i].(golang.GoResolutionResult); ok { + return &r + } + } + return nil +} + +// replaceResolution returns markers with any existing GoResolutionResult swapped +// for the freshly-resolved one. +func replaceResolution(m java.Markers, mrr golang.GoResolutionResult) java.Markers { + out := make([]java.Marker, 0, len(m.Entries)+1) + for _, e := range m.Entries { + if _, ok := e.(golang.GoResolutionResult); ok { + continue + } + out = append(out, e) + } + out = append(out, mrr) + return java.Markers{ID: m.ID, Entries: out} +} diff --git a/rewrite-go/pkg/recipe/golang/module_resolution_visitor.go b/rewrite-go/pkg/recipe/golang/module_resolution_visitor.go new file mode 100644 index 00000000000..dfecb0e2ae1 --- /dev/null +++ b/rewrite-go/pkg/recipe/golang/module_resolution_visitor.go @@ -0,0 +1,54 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package golang + +import ( + "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" + "github.com/openrewrite/rewrite/rewrite-go/pkg/visitor" +) + +// UpdateGoModModel returns a visitor that re-resolves the go.mod's +// GoResolutionResult marker via RefreshModel and replaces it. It is the +// composable counterpart to rewrite-maven's UpdateMavenModel: a recipe that +// mutates dependencies edits go.mod and then schedules this to run afterward — +// +// v.DoAfterVisit(golang.UpdateGoModModel()) +// +// so the refreshed model is visible to subsequent recipes in the same run. +// It works for any edit shape (add, remove, or modify a require/replace) because +// it operates on the final go.mod rather than intercepting the edit. +func UpdateGoModModel() recipe.TreeVisitor { + return visitor.Init(&updateGoModModelVisitor{}) +} + +type updateGoModModelVisitor struct { + visitor.GoVisitor +} + +func (v *updateGoModModelVisitor) VisitGoMod(gm *golang.GoMod, p any) java.Tree { + ctx, ok := p.(*recipe.ExecutionContext) + if !ok { + return gm + } + marker, ok := RefreshModel(gm, ctx) + if !ok { + return gm + } + return gm.WithMarkers(replaceResolution(gm.Markers, marker)) +} diff --git a/rewrite-go/pkg/rpc/go_resolution_result_codec.go b/rewrite-go/pkg/rpc/go_resolution_result_codec.go index 193eb4d56e7..e070d047506 100644 --- a/rewrite-go/pkg/rpc/go_resolution_result_codec.go +++ b/rewrite-go/pkg/rpc/go_resolution_result_codec.go @@ -39,6 +39,9 @@ import ( // 9. retracts (List, ref-by-key) // // 10. resolvedDependencies (List, ref-by-key) +// 11. buildList (List, ref-by-key) +// 12. graph (List, ref-by-key) +// 13. graphComplete (boolean) // // Each list element invokes its own rpcSend on the Java side; we mirror // the same field order in the per-element onChange callback. @@ -110,6 +113,39 @@ func sendGoResolutionResult(m golang.GoResolutionResult, q *SendQueue) { q.GetAndSend(d, func(y any) any { return emptyAsNil(y.(golang.GoResolvedDependency).ModuleHash) }, nil) q.GetAndSend(d, func(y any) any { return emptyAsNil(y.(golang.GoResolvedDependency).GoModHash) }, nil) }) + + q.GetAndSendListAsRef(m, + func(x any) []any { return moduleSlice(x.(golang.GoResolutionResult).BuildList) }, + func(x any) any { + b := x.(golang.GoModule) + return b.ModulePath + "@" + b.Version + }, + func(x any) { + b := x.(golang.GoModule) + q.GetAndSend(b, func(y any) any { return y.(golang.GoModule).ModulePath }, nil) + q.GetAndSend(b, func(y any) any { return y.(golang.GoModule).Version }, nil) + q.GetAndSend(b, func(y any) any { return emptyAsNil(y.(golang.GoModule).GoVersion) }, nil) + q.GetAndSend(b, func(y any) any { return y.(golang.GoModule).Main }, nil) + q.GetAndSend(b, func(y any) any { return emptyAsNil(y.(golang.GoModule).ModuleHash) }, nil) + q.GetAndSend(b, func(y any) any { return emptyAsNil(y.(golang.GoModule).GoModHash) }, nil) + }) + + q.GetAndSendListAsRef(m, + func(x any) []any { return edgeSlice(x.(golang.GoResolutionResult).Graph) }, + func(x any) any { + e := x.(golang.GoModuleEdge) + return e.FromPath + "@" + e.FromVersion + "->" + e.ToPath + "@" + e.ToVersion + }, + func(x any) { + e := x.(golang.GoModuleEdge) + q.GetAndSend(e, func(y any) any { return y.(golang.GoModuleEdge).FromPath }, nil) + q.GetAndSend(e, func(y any) any { return emptyAsNil(y.(golang.GoModuleEdge).FromVersion) }, nil) + q.GetAndSend(e, func(y any) any { return y.(golang.GoModuleEdge).ToPath }, nil) + q.GetAndSend(e, func(y any) any { return emptyAsNil(y.(golang.GoModuleEdge).ToVersion) }, nil) + q.GetAndSend(e, func(y any) any { return y.(golang.GoModuleEdge).Indirect }, nil) + }) + + q.GetAndSend(m, func(x any) any { return x.(golang.GoResolutionResult).GraphComplete }, nil) } // receiveGoResolutionResult mirrors Java's @@ -131,9 +167,75 @@ func receiveGoResolutionResult(before golang.GoResolutionResult, q *ReceiveQueue before.Excludes = recvExcludes(q, before.Excludes) before.Retracts = recvRetracts(q, before.Retracts) before.ResolvedDependencies = recvResolvedDeps(q, before.ResolvedDependencies) + before.BuildList = recvBuildList(q, before.BuildList) + before.Graph = recvGraph(q, before.Graph) + before.GraphComplete = receiveScalar[bool](q, before.GraphComplete) return before } +func recvBuildList(q *ReceiveQueue, before []golang.GoModule) []golang.GoModule { + afterAny := q.ReceiveList(moduleSlice(before), func(v any) any { + b := v.(golang.GoModule) + b.ModulePath = receiveScalar[string](q, b.ModulePath) + b.Version = receiveScalar[string](q, b.Version) + b.GoVersion = receiveNullableString(q, b.GoVersion) + b.Main = receiveScalar[bool](q, b.Main) + b.ModuleHash = receiveNullableString(q, b.ModuleHash) + b.GoModHash = receiveNullableString(q, b.GoModHash) + return b + }) + if afterAny == nil { + return nil + } + out := make([]golang.GoModule, len(afterAny)) + for i, v := range afterAny { + out[i] = v.(golang.GoModule) + } + return out +} + +func recvGraph(q *ReceiveQueue, before []golang.GoModuleEdge) []golang.GoModuleEdge { + afterAny := q.ReceiveList(edgeSlice(before), func(v any) any { + e := v.(golang.GoModuleEdge) + e.FromPath = receiveScalar[string](q, e.FromPath) + e.FromVersion = receiveNullableString(q, e.FromVersion) + e.ToPath = receiveScalar[string](q, e.ToPath) + e.ToVersion = receiveNullableString(q, e.ToVersion) + e.Indirect = receiveScalar[bool](q, e.Indirect) + return e + }) + if afterAny == nil { + return nil + } + out := make([]golang.GoModuleEdge, len(afterAny)) + for i, v := range afterAny { + out[i] = v.(golang.GoModuleEdge) + } + return out +} + +func moduleSlice(s []golang.GoModule) []any { + if s == nil { + return nil + } + out := make([]any, len(s)) + for i, v := range s { + out[i] = v + } + return out +} + +func edgeSlice(s []golang.GoModuleEdge) []any { + if s == nil { + return nil + } + out := make([]any, len(s)) + for i, v := range s { + out[i] = v + } + return out +} + func recvRequires(q *ReceiveQueue, before []golang.GoRequire) []golang.GoRequire { beforeAny := requireSlice(before) afterAny := q.ReceiveList(beforeAny, func(v any) any { diff --git a/rewrite-go/pkg/rpc/gomod_codec_test.go b/rewrite-go/pkg/rpc/gomod_codec_test.go index 2999a9ce698..33ed063c4ac 100644 --- a/rewrite-go/pkg/rpc/gomod_codec_test.go +++ b/rewrite-go/pkg/rpc/gomod_codec_test.go @@ -91,3 +91,61 @@ func TestGoModRPCPreservesResolutionMarker(t *testing.T) { t.Fatalf("marker fields not preserved: %#v", found) } } + +// TestGoModRPCPreservesModuleGraph verifies the resolved module graph fields +// added to GoResolutionResult survive the Go↔wire round-trip in lockstep with +// the Java codec. +func TestGoModRPCPreservesModuleGraph(t *testing.T) { + content := "module example.com/foo\n\ngo 1.21\n\nrequire github.com/x/y v1.2.3\n" + before, err := parser.ParseGoModFile("go.mod", content) + if err != nil { + t.Fatalf("parse error: %v", err) + } + marker := golang.NewGoResolutionResult("example.com/foo", "1.21", "", "go.mod") + marker.BuildList = []golang.GoModule{ + {ModulePath: "example.com/foo", Version: "", GoVersion: "1.21", Main: true}, + {ModulePath: "github.com/x/y", Version: "v1.2.3", GoVersion: "1.18", + ModuleHash: "h1:zip=", GoModHash: "h1:mod="}, + {ModulePath: "github.com/x/dep", Version: "v0.4.0", GoVersion: "1.16", GoModHash: "h1:onlymod="}, + } + marker.Graph = []golang.GoModuleEdge{ + {FromPath: "example.com/foo", FromVersion: "", ToPath: "github.com/x/y", ToVersion: "v1.2.3"}, + {FromPath: "github.com/x/y", FromVersion: "v1.2.3", ToPath: "github.com/x/dep", ToVersion: "v0.4.0", Indirect: true}, + } + marker.GraphComplete = true + before.Markers.Entries = append(before.Markers.Entries, marker) + + seed := &golang.GoMod{Ident: before.Ident} + got := roundTripNode(t, before, seed).(*golang.GoMod) + + var found *golang.GoResolutionResult + for i := range got.Markers.Entries { + if r, ok := got.Markers.Entries[i].(golang.GoResolutionResult); ok { + found = &r + } + } + if found == nil { + t.Fatalf("GoResolutionResult marker lost in round-trip") + } + if !found.GraphComplete { + t.Errorf("GraphComplete not preserved") + } + if len(found.BuildList) != 3 { + t.Fatalf("BuildList length: want 3, got %d", len(found.BuildList)) + } + xy := found.BuildList[1] + if xy.ModulePath != "github.com/x/y" || xy.Version != "v1.2.3" || xy.GoVersion != "1.18" || + xy.ModuleHash != "h1:zip=" || xy.GoModHash != "h1:mod=" { + t.Errorf("BuildList[1] not preserved: %#v", xy) + } + if !found.BuildList[0].Main { + t.Errorf("main module flag not preserved") + } + if len(found.Graph) != 2 { + t.Fatalf("Graph length: want 2, got %d", len(found.Graph)) + } + e := found.Graph[1] + if e.FromPath != "github.com/x/y" || e.ToPath != "github.com/x/dep" || e.ToVersion != "v0.4.0" || !e.Indirect { + t.Errorf("Graph[1] edge not preserved: %#v", e) + } +} diff --git a/rewrite-go/pkg/rpc/value_types.go b/rewrite-go/pkg/rpc/value_types.go index f6dead60c91..90a42d0fb96 100644 --- a/rewrite-go/pkg/rpc/value_types.go +++ b/rewrite-go/pkg/rpc/value_types.go @@ -130,6 +130,8 @@ func init() { RegisterValueType(reflect.TypeOf(golang.GoExclude{}), "org.openrewrite.golang.marker.GoResolutionResult$Exclude") RegisterValueType(reflect.TypeOf(golang.GoRetract{}), "org.openrewrite.golang.marker.GoResolutionResult$Retract") RegisterValueType(reflect.TypeOf(golang.GoResolvedDependency{}), "org.openrewrite.golang.marker.GoResolutionResult$ResolvedDependency") + RegisterValueType(reflect.TypeOf(golang.GoModule{}), "org.openrewrite.golang.marker.GoResolutionResult$GoModule") + RegisterValueType(reflect.TypeOf(golang.GoModuleEdge{}), "org.openrewrite.golang.marker.GoResolutionResult$GoModuleEdge") // JavaType types RegisterValueType(reflect.TypeOf((*java.JavaTypeClass)(nil)), "org.openrewrite.java.tree.JavaType$Class") @@ -267,6 +269,8 @@ func init() { RegisterFactory("org.openrewrite.golang.marker.GoResolutionResult$Exclude", func() any { return golang.GoExclude{} }) RegisterFactory("org.openrewrite.golang.marker.GoResolutionResult$Retract", func() any { return golang.GoRetract{} }) RegisterFactory("org.openrewrite.golang.marker.GoResolutionResult$ResolvedDependency", func() any { return golang.GoResolvedDependency{} }) + RegisterFactory("org.openrewrite.golang.marker.GoResolutionResult$GoModule", func() any { return golang.GoModule{} }) + RegisterFactory("org.openrewrite.golang.marker.GoResolutionResult$GoModuleEdge", func() any { return golang.GoModuleEdge{} }) RegisterFactory("org.openrewrite.java.tree.Space", func() any { return java.Space{} }) RegisterFactory("org.openrewrite.marker.Markers", func() any { return java.Markers{} }) diff --git a/rewrite-go/pkg/tree/golang/go_resolution_result.go b/rewrite-go/pkg/tree/golang/go_resolution_result.go index f6dd545c0ba..edeeba3fe02 100644 --- a/rewrite-go/pkg/tree/golang/go_resolution_result.go +++ b/rewrite-go/pkg/tree/golang/go_resolution_result.go @@ -34,6 +34,45 @@ type GoResolutionResult struct { Excludes []GoExclude Retracts []GoRetract ResolvedDependencies []GoResolvedDependency + + // --- resolved module graph (populated at parse time by pkg/parser/modgraph) --- + // + // These fields carry the transitive module graph so that recipes (e.g. + // GoModTidy Phase 2) can prune unused indirect requires, bump the `go` + // directive, and generate go.sum WITHOUT any I/O or toolchain access at + // recipe time. They are best-effort: if the module cache / proxy could + // not be fully traversed at parse time, GraphComplete is false and the + // data is partial. + + // BuildList is the MVS-selected version of every module in the graph, + // including the main module. Mirrors `go list -m all`. + BuildList []GoModule + // Graph holds the require edges between modules (the data that the main + // module's go.mod alone does not contain). Mirrors `go mod graph`. + Graph []GoModuleEdge + // GraphComplete reports whether BuildList/Graph were fully resolved. + GraphComplete bool +} + +// GoModule is one node of the resolved module graph: a module at its +// MVS-selected version, with the metadata needed for tidy operations. +type GoModule struct { + ModulePath string + Version string // "" for the main module + GoVersion string // the module's OWN `go` directive — drives go-directive bump + Main bool + ModuleHash string // h1: zip hash from the cache `.ziphash` — for go.sum + GoModHash string // h1: hash of the module's go.mod — for go.sum +} + +// GoModuleEdge is a require edge `From` -> `To` in the resolved graph. +// Indirect reflects whether the require in From's go.mod was `// indirect`. +type GoModuleEdge struct { + FromPath string + FromVersion string + ToPath string + ToVersion string + Indirect bool } func (m GoResolutionResult) ID() uuid.UUID { return m.Ident } @@ -115,5 +154,8 @@ func NewGoResolutionResult(modulePath, goVersion, toolchain, path string) GoReso Excludes: []GoExclude{}, Retracts: []GoRetract{}, ResolvedDependencies: []GoResolvedDependency{}, + BuildList: []GoModule{}, + Graph: []GoModuleEdge{}, + GraphComplete: false, } } diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/GoModParser.java b/rewrite-go/src/main/java/org/openrewrite/golang/GoModParser.java index 3fa865b560a..d5ab2c51002 100644 --- a/rewrite-go/src/main/java/org/openrewrite/golang/GoModParser.java +++ b/rewrite-go/src/main/java/org/openrewrite/golang/GoModParser.java @@ -233,7 +233,12 @@ public Path sourcePathFromSourceText(Path prefix, String sourceCode) { replaces, excludes, retracts, - resolved + resolved, + // Module graph is resolved separately (by the Go modgraph resolver at + // parse time); the Java text parser leaves it empty. + new ArrayList<>(), + new ArrayList<>(), + false ); } diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/marker/GoResolutionResult.java b/rewrite-go/src/main/java/org/openrewrite/golang/marker/GoResolutionResult.java index ca9865c3d8e..84d023cc315 100644 --- a/rewrite-go/src/main/java/org/openrewrite/golang/marker/GoResolutionResult.java +++ b/rewrite-go/src/main/java/org/openrewrite/golang/marker/GoResolutionResult.java @@ -102,6 +102,24 @@ public class GoResolutionResult implements Marker, RpcCodec */ List resolvedDependencies; + /** + * The MVS-selected version of every module in the resolved graph (including the main module). + * Mirrors {@code go list -m all}. Populated at parse time by the Go {@code modgraph} resolver. + */ + List buildList; + + /** + * Require edges between modules — the transitive graph the main module's go.mod alone does + * not contain. Mirrors {@code go mod graph}. + */ + List graph; + + /** + * Whether {@link #buildList}/{@link #graph} were fully resolved. False when the module cache + * or proxy could not be fully traversed; consumers then treat the graph as partial. + */ + boolean graphComplete; + public @Nullable Require findRequire(String module) { for (Require r : requires) { if (r.getModulePath().equals(module)) { @@ -142,6 +160,13 @@ public void rpcSend(GoResolutionResult after, RpcSendQueue q) { q.getAndSendListAsRef(after, r -> r.getResolvedDependencies() != null ? r.getResolvedDependencies() : emptyList(), rd -> rd.getModulePath() + "@" + rd.getVersion(), rd -> rd.rpcSend(rd, q)); + q.getAndSendListAsRef(after, r -> r.getBuildList() != null ? r.getBuildList() : emptyList(), + m -> m.getModulePath() + "@" + m.getVersion(), + m -> m.rpcSend(m, q)); + q.getAndSendListAsRef(after, r -> r.getGraph() != null ? r.getGraph() : emptyList(), + e -> e.getFromPath() + "@" + e.getFromVersion() + "->" + e.getToPath() + "@" + e.getToVersion(), + e -> e.rpcSend(e, q)); + q.getAndSend(after, GoResolutionResult::isGraphComplete); } @Override @@ -156,7 +181,10 @@ public GoResolutionResult rpcReceive(GoResolutionResult before, RpcReceiveQueue .withReplaces(q.receiveList(before.replaces, r -> r.rpcReceive(r, q))) .withExcludes(q.receiveList(before.excludes, r -> r.rpcReceive(r, q))) .withRetracts(q.receiveList(before.retracts, r -> r.rpcReceive(r, q))) - .withResolvedDependencies(q.receiveList(before.resolvedDependencies, r -> r.rpcReceive(r, q))); + .withResolvedDependencies(q.receiveList(before.resolvedDependencies, r -> r.rpcReceive(r, q))) + .withBuildList(q.receiveList(before.buildList, m -> m.rpcReceive(m, q))) + .withGraph(q.receiveList(before.graph, e -> e.rpcReceive(e, q))) + .withGraphComplete(q.receive(before.graphComplete)); } /** @@ -312,4 +340,93 @@ public ResolvedDependency rpcReceive(ResolvedDependency before, RpcReceiveQueue .withGoModHash(q.receive(before.goModHash)); } } + + /** + * One node of the resolved module graph: a module at its MVS-selected version. + * {@link #moduleHash} (zip hash) is present only when the module's packages are + * actually imported; {@link #goModHash} is present for the whole build list. + */ + @Value + @With + @JsonIdentityInfo(generator = ObjectIdGenerators.IntSequenceGenerator.class, property = "@ref") + public static class GoModule implements RpcCodec { + @ToString.Include + String modulePath; + + /** Empty for the main module. */ + String version; + + /** The module's own {@code go} directive — drives go-directive bump. */ + @Nullable String goVersion; + + boolean main; + + /** h1: zip hash from the cache; null when the module's zip was not downloaded. */ + @Nullable String moduleHash; + + /** h1: hash of the module's go.mod. */ + @Nullable String goModHash; + + @Override + public void rpcSend(GoModule after, RpcSendQueue q) { + q.getAndSend(after, GoModule::getModulePath); + q.getAndSend(after, GoModule::getVersion); + q.getAndSend(after, GoModule::getGoVersion); + q.getAndSend(after, GoModule::isMain); + q.getAndSend(after, GoModule::getModuleHash); + q.getAndSend(after, GoModule::getGoModHash); + } + + @Override + public GoModule rpcReceive(GoModule before, RpcReceiveQueue q) { + return before + .withModulePath(q.receive(before.modulePath)) + .withVersion(q.receive(before.version)) + .withGoVersion(q.receive(before.goVersion)) + .withMain(q.receive(before.main)) + .withModuleHash(q.receive(before.moduleHash)) + .withGoModHash(q.receive(before.goModHash)); + } + } + + /** + * A require edge {@code from -> to} in the resolved graph. {@link #indirect} reflects + * whether the require in {@code from}'s go.mod was {@code // indirect}. + */ + @Value + @With + @JsonIdentityInfo(generator = ObjectIdGenerators.IntSequenceGenerator.class, property = "@ref") + public static class GoModuleEdge implements RpcCodec { + @ToString.Include + String fromPath; + + /** Empty when the edge originates at the main module. */ + @Nullable String fromVersion; + + @ToString.Include + String toPath; + + @Nullable String toVersion; + + boolean indirect; + + @Override + public void rpcSend(GoModuleEdge after, RpcSendQueue q) { + q.getAndSend(after, GoModuleEdge::getFromPath); + q.getAndSend(after, GoModuleEdge::getFromVersion); + q.getAndSend(after, GoModuleEdge::getToPath); + q.getAndSend(after, GoModuleEdge::getToVersion); + q.getAndSend(after, GoModuleEdge::isIndirect); + } + + @Override + public GoModuleEdge rpcReceive(GoModuleEdge before, RpcReceiveQueue q) { + return before + .withFromPath(q.receive(before.fromPath)) + .withFromVersion(q.receive(before.fromVersion)) + .withToPath(q.receive(before.toPath)) + .withToVersion(q.receive(before.toVersion)) + .withIndirect(q.receive(before.indirect)); + } + } } diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java b/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java index f82396d4fbe..8023a840c97 100644 --- a/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java +++ b/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java @@ -18,6 +18,7 @@ import lombok.Getter; import org.jspecify.annotations.Nullable; import org.openrewrite.ExecutionContext; +import org.openrewrite.HttpSenderExecutionContextView; import org.openrewrite.Parser; import org.openrewrite.SourceFile; import org.openrewrite.golang.GolangParser; @@ -235,6 +236,9 @@ public Stream parseProject(Path projectPath, @Nullable List * @return Stream of parsed source files */ public Stream parseProject(Path projectPath, @Nullable List exclusions, @Nullable Path relativeTo, ExecutionContext ctx) { + // Route the Go module-graph resolver's GOPROXY fetches through the + // CLI-configured HttpSender (handled by the "Http" RPC method). + setHttpSender(HttpSenderExecutionContextView.view(ctx).getHttpSender()); ParsingEventListener parsingListener = ParsingExecutionContextView.view(ctx).getParsingListener(); return StreamSupport.stream(new Spliterator() { diff --git a/rewrite-go/test/go_mod_resolution_test.go b/rewrite-go/test/go_mod_resolution_test.go new file mode 100644 index 00000000000..89c328adcbc --- /dev/null +++ b/rewrite-go/test/go_mod_resolution_test.go @@ -0,0 +1,131 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" + "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" + golangrecipe "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe/golang" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" +) + +// TestRefreshModelRecipeTime exercises the Maven-style model refresh at recipe +// time: with a ModSource installed in the ExecutionContext (as the RPC server +// does), RefreshModel re-parses the declared go.mod and re-resolves the build +// list, and the UpdateGoModModel visitor swaps the GoResolutionResult marker. +// This is how a dependency-mutating recipe keeps the model current for the next +// recipe in the run. +func TestRefreshModelRecipeTime(t *testing.T) { + if testing.Short() { + t.Skip("needs the go toolchain + module cache") + } + dir := t.TempDir() + mustWrite(t, dir, "go.mod", "module example.com/rt\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n") + mustWrite(t, dir, "main.go", "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n") + mustGo(t, dir, "mod", "tidy") + gomodcache := strings.TrimSpace(mustGo(t, dir, "env", "GOMODCACHE")) + content, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + t.Fatal(err) + } + gm, err := parser.ParseGoModFile("go.mod", string(content)) + if err != nil { + t.Fatal(err) + } + + ctx := recipe.NewExecutionContext() + golangrecipe.SetModSource(ctx, modgraph.CacheSource(gomodcache)) + + // RefreshModel: declared requires + resolved build list. + marker, ok := golangrecipe.RefreshModel(gm, ctx) + if !ok { + t.Fatal("RefreshModel returned ok=false") + } + if len(marker.BuildList) == 0 { + t.Errorf("expected a non-empty resolved build list") + } + direct, indirect := requireSets(marker) + for _, p := range []string{"github.com/grafana/pyroscope-go", "golang.org/x/mod"} { + if !direct[p] { + t.Errorf("expected %s declared as a direct require", p) + } + } + for _, p := range []string{"github.com/grafana/pyroscope-go/godeltaprof", "github.com/klauspost/compress"} { + if !indirect[p] { + t.Errorf("expected %s declared as an indirect require", p) + } + } + + // UpdateGoModModel (the doAfterVisit-scheduled refresh) swaps the marker. + res := golangrecipe.UpdateGoModModel().Visit(gm, ctx) + gm2, ok := res.(*golang.GoMod) + if !ok { + t.Fatalf("UpdateGoModModel returned %T", res) + } + rr := golangrecipe.GetResolutionResult(gm2) + if rr == nil || len(rr.BuildList) == 0 { + t.Errorf("expected UpdateGoModModel to attach a resolved marker") + } + + // Without a ModSource the declared model still refreshes, but the resolved + // build list is empty (no network/cache access configured). + m2, ok := golangrecipe.RefreshModel(gm, recipe.NewExecutionContext()) + if !ok { + t.Errorf("expected declared refresh to succeed without a source") + } + if len(m2.BuildList) != 0 { + t.Errorf("expected no build list without a ModSource, got %d", len(m2.BuildList)) + } +} + +func requireSets(m golang.GoResolutionResult) (direct, indirect map[string]bool) { + direct, indirect = map[string]bool{}, map[string]bool{} + for _, r := range m.Requires { + if r.Indirect { + indirect[r.ModulePath] = true + } else { + direct[r.ModulePath] = true + } + } + return direct, indirect +} + +func mustWrite(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func mustGo(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("go", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} diff --git a/rewrite-go/test/go_mod_tidy_recipe_test.go b/rewrite-go/test/go_mod_tidy_recipe_test.go new file mode 100644 index 00000000000..728535cc2c3 --- /dev/null +++ b/rewrite-go/test/go_mod_tidy_recipe_test.go @@ -0,0 +1,96 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test + +import ( + "testing" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" + "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" + "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" + golangrecipe "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe/golang" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" +) + +// runTidy drives GoModTidy's scan->edit flow the way the RPC engine does: +// scan every .go file plus the go.mod into the accumulator, then run the +// editor over the go.mod. Returns the printed go.mod. +func runTidy(t *testing.T, goMod string, goFiles map[string]string) string { + t.Helper() + r := &golangrecipe.GoModTidy{} + ctx := recipe.NewExecutionContext() + acc := r.InitialValue(ctx) + + scanner := r.Scanner(acc) + p := parser.NewGoParser() + for path, content := range goFiles { + cu, err := p.Parse(path, content) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + scanner.Visit(cu, ctx) + } + gm, err := parser.ParseGoModFile("go.mod", goMod) + if err != nil { + t.Fatalf("parse go.mod: %v", err) + } + scanner.Visit(gm, ctx) + + editor := r.EditorWithData(acc) + res := editor.Visit(gm, ctx) + return printer.PrintGoMod(res.(*golang.GoMod)) +} + +// Perturbation: a directly-imported module wrongly marked `// indirect` +// should have the comment removed. +func TestTidyRemovesWrongIndirect(t *testing.T) { + before := "module example.com/foo\n\ngo 1.21\n\nrequire github.com/a/b v1.0.0 // indirect\n" + want := "module example.com/foo\n\ngo 1.21\n\nrequire github.com/a/b v1.0.0\n" + goFiles := map[string]string{ + "main.go": "package main\n\nimport \"github.com/a/b\"\n\nfunc main() { _ = b.X }\n", + } + if got := runTidy(t, before, goFiles); got != want { + t.Errorf("\nwant: %q\ngot: %q", want, got) + } +} + +// Perturbation: a module not imported anywhere, missing its `// indirect` +// marker, should get one added. +func TestTidyAddsMissingIndirect(t *testing.T) { + // go >= 1.17: tidy splits the directly-imported dep into its own require + // and the unused one into a separate indirect require. + before := "module example.com/foo\n\ngo 1.21\n\nrequire (\n\tgithub.com/a/b v1.0.0\n\tgithub.com/c/d v1.5.0\n)\n" + want := "module example.com/foo\n\ngo 1.21\n\nrequire github.com/a/b v1.0.0\n\nrequire github.com/c/d v1.5.0 // indirect\n" + goFiles := map[string]string{ + "main.go": "package main\n\nimport \"github.com/a/b\"\n\nfunc main() { _ = b.X }\n", + } + if got := runTidy(t, before, goFiles); got != want { + t.Errorf("\nwant: %q\ngot: %q", want, got) + } +} + +// Perturbation: out-of-order require entries should be sorted by module path. +func TestTidySortsRequireBlock(t *testing.T) { + before := "module example.com/foo\n\ngo 1.21\n\nrequire (\n\tgithub.com/c/d v1.5.0\n\tgithub.com/a/b v1.0.0\n)\n" + want := "module example.com/foo\n\ngo 1.21\n\nrequire (\n\tgithub.com/a/b v1.0.0\n\tgithub.com/c/d v1.5.0\n)\n" + goFiles := map[string]string{ + "main.go": "package main\n\nimport (\n\t\"github.com/a/b\"\n\t\"github.com/c/d\"\n)\n\nfunc main() { _, _ = b.X, d.Y }\n", + } + if got := runTidy(t, before, goFiles); got != want { + t.Errorf("\nwant: %q\ngot: %q", want, got) + } +} From 5c18cb145a7712d1df1f595a0d3c2df6d9d421c8 Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Sat, 20 Jun 2026 00:17:52 -0700 Subject: [PATCH 02/19] Go: immutable RPC visitor + cross-language whitespace round-trip fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes whitespace loss when a recipe edits .go sources through the moderne-cli RPC pipeline, and hardens parsing/serialization for the GoModTidy recipe end-to-end. Whitespace round-trip fix (pkg/rpc/java_receiver.go): for J nodes whose Go model holds a direct child where Java's model wraps it (JRightPadded/Container) — J.If then/else parts, for/forEach bodies, J.Switch selector, J.Case statements — the receiver passed a nil baseline to q.Receive. On a CHANGE delta that materialized a fresh empty instance, so unchanged inner Spaces resolved NO_CHANGE -> empty and the subtree collapsed (`if x == 1 {` -> `if x == 1{`). Now each site passes the baseline wrapped exactly as the sender wraps it, so inner spaces diff against the real baseline. Captured the real failing wire as a replayable regression test (print_collapse_repro_test.go + testdata). Immutable LST: withX methods return the receiver unchanged when the argument matches the current value, and the Go visitor copies-on-write, so unchanged subtrees keep pointer identity and produce no spurious RPC deltas (the root cause of over-broad patches). Unified RPC state to match the other parsers (Java/JS/C#/Python): a single remoteObjects baseline + single ref tables shared by both directions, replacing the split reverse* maps. handleVisit stores results in localObjects only. Parsing: files that fail to parse become a ParseError inline rather than being silently dropped or downgraded to PlainText; literal normalization avoids NumberFormatException on Go integer syntax (hex/octal/underscore/ runes); GoModTidy harvests imports from PlainText .go files for build-excluded/other-arch sources. Validated on 12 real projects (~3500 .go files): 0 ParseError, 0 PlainText, recipe touches 0 .go files, and direct-dependency parity with `go mod tidy` in every project. --- rewrite-go/cmd/rpc/main.go | 225 ++- rewrite-go/cmd/rpc/receive_resilience_test.go | 16 +- rewrite-go/pkg/parser/go_parser.go | 285 ++- rewrite-go/pkg/printer/go_printer.go | 36 +- rewrite-go/pkg/recipe/golang/go_mod_tidy.go | 32 +- .../golang/go_mod_tidy_plaintext_test.go | 62 + rewrite-go/pkg/rpc/go_receiver.go | 75 +- rewrite-go/pkg/rpc/go_unary_rpc_test.go | 55 + rewrite-go/pkg/rpc/java_receiver.go | 17 +- .../pkg/rpc/left_padded_operator_rpc_test.go | 15 +- rewrite-go/pkg/rpc/padding_rpc.go | 28 +- .../pkg/rpc/print_collapse_repro_test.go | 138 ++ rewrite-go/pkg/rpc/space_rpc.go | 9 +- .../rpc/testdata/print_collapse_baseline.json | 1 + .../pkg/rpc/testdata/print_collapse_wire.json | 1 + rewrite-go/pkg/rpc/value_types.go | 14 + rewrite-go/pkg/test/spec.go | 32 +- rewrite-go/pkg/tree/golang/go.go | 213 ++ rewrite-go/pkg/tree/golang/gomod.go | 39 + rewrite-go/pkg/tree/java/equality.go | 68 + rewrite-go/pkg/tree/java/j.go | 291 +++ rewrite-go/pkg/tree/java/parse_error.go | 19 +- rewrite-go/pkg/tree/java/plain_text.go | 43 + rewrite-go/pkg/visitor/go_visitor.go | 1731 ++++++++++++----- rewrite-go/test/build_tags_test.go | 20 +- rewrite-go/test/file_imports_test.go | 57 + rewrite-go/test/immutability_test.go | 84 + rewrite-go/test/literal_value_test.go | 115 ++ rewrite-go/test/multi_file_package_test.go | 15 +- rewrite-go/test/parse_failure_test.go | 103 + rewrite-go/test/struct_tag_test.go | 54 + 31 files changed, 3178 insertions(+), 715 deletions(-) create mode 100644 rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go create mode 100644 rewrite-go/pkg/rpc/go_unary_rpc_test.go create mode 100644 rewrite-go/pkg/rpc/print_collapse_repro_test.go create mode 100644 rewrite-go/pkg/rpc/testdata/print_collapse_baseline.json create mode 100644 rewrite-go/pkg/rpc/testdata/print_collapse_wire.json create mode 100644 rewrite-go/pkg/tree/java/equality.go create mode 100644 rewrite-go/pkg/tree/java/plain_text.go create mode 100644 rewrite-go/test/file_imports_test.go create mode 100644 rewrite-go/test/immutability_test.go create mode 100644 rewrite-go/test/literal_value_test.go create mode 100644 rewrite-go/test/parse_failure_test.go diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index 74fc4bed94a..9fbdf1fc3c3 100644 --- a/rewrite-go/cmd/rpc/main.go +++ b/rewrite-go/cmd/rpc/main.go @@ -77,18 +77,23 @@ type rpcError struct { } // server holds the RPC state. +// +// A SINGLE unified baseline is shared by both directions, matching every other +// language peer (Java RewriteRpc, rewrite-javascript, rewrite-csharp, +// rewrite-python): remoteObjects is "the last state of an object that Go and +// Java agree on" and is used as the diff baseline whether Go is serving a +// GetObject (Java pulls from Go) or pulling one (Go pulls from Java). localRefs +// is the persistent SEND ref table (object → ref id); remoteRefs is the +// persistent RECEIVE ref table (ref id → object). An earlier split into +// reverse* maps desynced the two sides' baselines/refs and dropped unchanged +// subtrees' data (whitespace) on the print transport. type server struct { localObjects map[string]any - remoteObjects map[string]any // forward direction: tracks what Java has from Go - localRefs map[uintptr]int - remoteRefs map[int]any + remoteObjects map[string]any // last-synced state shared by both directions + localRefs map[uintptr]int // SEND ref table (persistent) + remoteRefs map[int]any // RECEIVE ref table (persistent) batchSize int - // Separate state for reverse GetObject (Java→Go) to avoid conflating - // with forward direction state - reverseRemoteObjects map[string]any - reverseRemoteRefs map[int]any - // Prepared recipe instances keyed by unique ID preparedRecipes map[string]recipe.Recipe @@ -123,8 +128,8 @@ type server struct { metricsWriter *csv.Writer metricsMu sync.Mutex - reader *bufio.Reader - writer io.Writer + reader *bufio.Reader + writer io.Writer // httpMu serializes bidirectional Http requests so concurrent fetches do // not interleave on the single duplex stream. httpMu sync.Mutex @@ -205,8 +210,6 @@ func newServer(cfg serverConfig) *server { remoteObjects: make(map[string]any), localRefs: make(map[uintptr]int), remoteRefs: make(map[int]any), - reverseRemoteObjects: make(map[string]any), - reverseRemoteRefs: make(map[int]any), preparedRecipes: make(map[string]recipe.Recipe), preparedEditorOverrides: make(map[string]recipe.TreeVisitor), preparedAccumulators: make(map[string]any), @@ -472,6 +475,11 @@ func (s *server) handleGetLanguages() []string { return []string{ "org.openrewrite.golang.tree.Go$CompilationUnit", "org.openrewrite.golang.tree.GoMod", + // PlainText is the CLI Go build step's fallback for any .go file the + // parser doesn't return (e.g. files excluded by the host build + // context). Accepting it lets GoModTidy read those files' imports — + // `go mod tidy` unions imports across all build configurations. + "org.openrewrite.text.PlainText", } } @@ -623,43 +631,47 @@ func (s *server) handleParse(params json.RawMessage) (any, *rpcError) { groups[dir] = append(groups[dir], fileEntry{idx: r.idx, input: goparser.FileInput{Path: r.sourcePath, Content: r.source}}) } - // Parse each group; collect CUs by their original input index so the - // returned IDs land in input-order. Pre-filter against the parser's - // BuildContext so the post-parse `cus` slice aligns 1:1 with the - // `included` subset of the group. + // Parse each group; collect results by their original input index so the + // returned IDs land in input-order. ParsePackage returns one SourceFile + // per build-included input — a CompilationUnit or, for a file that fails + // to parse, a ParseError — mapped back by source path. Build-excluded + // files are absent and fall through to the emit loop's fallback. cuByIdx := make(map[int]*golang.CompilationUnit, len(resolvedInputs)) - parseErrByIdx := make(map[int]error) + peByIdx := make(map[int]*java.ParseError) for _, group := range groups { - included := make([]fileEntry, 0, len(group)) files := make([]goparser.FileInput, 0, len(group)) + idxBySourcePath := make(map[string]int, len(group)) for _, g := range group { - if !goparser.MatchBuildContext(p.BuildContext, filepath.Base(g.input.Path), g.input.Content) { - continue - } - included = append(included, g) files = append(files, g.input) + idxBySourcePath[g.input.Path] = g.idx } - if len(files) == 0 { - continue - } - cus, err := func() (out []*golang.CompilationUnit, err error) { + sfs, err := func() (out []java.Tree, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }() - return p.ParsePackage(files) + return p.ParsePackage(files), nil }() if err != nil { - // Whole-package parse failure — record per-file ParseErrors - // for every file the build context didn't exclude. - for _, g := range included { - parseErrByIdx[g.idx] = err + // Whole-package panic — represent every file as a ParseError + // rather than dropping the group. + for _, g := range group { + peByIdx[g.idx] = java.NewParseError(g.input.Path, g.input.Content, err) } continue } - for i, cu := range cus { - cuByIdx[included[i].idx] = cu + for _, sf := range sfs { + switch v := sf.(type) { + case *golang.CompilationUnit: + if idx, ok := idxBySourcePath[v.SourcePath]; ok { + cuByIdx[idx] = v + } + case *java.ParseError: + if idx, ok := idxBySourcePath[v.SourcePath]; ok { + peByIdx[idx] = v + } + } } } @@ -674,7 +686,7 @@ func (s *server) handleParse(params json.RawMessage) (any, *rpcError) { } gm, err := goparser.ParseGoModFile(r.sourcePath, r.source) if err != nil { - parseErrByIdx[r.idx] = err + peByIdx[r.idx] = java.NewParseError(r.sourcePath, r.source, err) continue } if mrr, err := goparser.ParseGoMod(r.sourcePath, r.source); err == nil && mrr != nil && mrr.ModulePath != "" { @@ -683,7 +695,10 @@ func (s *server) handleParse(params json.RawMessage) (any, *rpcError) { goModByIdx[r.idx] = gm } - // Emit results in input order. + // Emit results in input order. Every input yields exactly one id (the + // Java side maps ids to inputs positionally): a CompilationUnit, a + // GoMod, or — for a file that failed to parse or was build-excluded — a + // ParseError. Nothing is silently dropped. ids := make([]string, 0, len(req.Inputs)) for _, r := range resolvedInputs { if cu, ok := cuByIdx[r.idx]; ok && cu != nil { @@ -698,12 +713,11 @@ func (s *server) handleParse(params json.RawMessage) (any, *rpcError) { ids = append(ids, id) continue } - err := parseErrByIdx[r.idx] - if err == nil { - err = fmt.Errorf("no compilation unit produced") + pe, ok := peByIdx[r.idx] + if !ok { + pe = java.NewParseError(r.sourcePath, r.source, fmt.Errorf("no compilation unit produced")) } - s.logger.Printf("Parse error for %s: %v", r.sourcePath, err) - pe := java.NewParseError(r.sourcePath, r.source, err) + s.logger.Printf("Parse error for %s: %v", r.sourcePath, pe.Cause()) id := pe.Ident.String() s.localObjects[id] = pe ids = append(ids, id) @@ -734,15 +748,14 @@ func (s *server) handleGetObject(params json.RawMessage) (any, *rpcError) { } before := s.remoteObjects[req.ID] - // Use a fresh ref map for each GetObject to avoid ref ID collisions - // between the reverse direction (Java→Go) and forward direction (Go→Java). - localRefs := make(map[uintptr]int) - // Collect all batches into a single result + // Collect all batches into a single result. The persistent send ref table + // (localRefs) is shared across GetObject calls so Java's receive ref table + // stays in sync — matching the other language peers. var result []rpc.RpcObjectData q := rpc.NewSendQueue(s.batchSize, func(batch []rpc.RpcObjectData) { result = append(result, batch...) - }, localRefs) + }, s.localRefs) sender := rpc.NewGoSender() q.Send(obj, before, func(v any) { @@ -917,10 +930,11 @@ func defaultModCache() string { // Supports multi-batch transfers: each GetObject call returns one batch, and // subsequent calls are made until the END_OF_OBJECT marker is received. func (s *server) getObjectFromJava(id string, sourceFileType string) any { - // Use reverse-direction tracking if available; otherwise fall back to - // localObjects (the tree Go originally parsed and sent to Java via forward - // GetObject). This matches Java's remoteObjects baseline. - before := s.reverseRemoteObjects[id] + // Diff baseline is the unified remoteObjects (the last state Go and Java + // agreed on) — the SAME map Java uses when it computes the diff it sends us, + // and the same one handleGetObject uses when serving. Fall back to + // localObjects on a cold cache (the tree Go parsed and sent earlier). + before := s.remoteObjects[id] if before == nil { before = s.localObjects[id] } @@ -973,7 +987,7 @@ func (s *server) getObjectFromJava(id string, sourceFileType string) any { return batch } - q := rpc.NewReceiveQueue(s.reverseRemoteRefs, fetchBatch) + q := rpc.NewReceiveQueue(s.remoteRefs, fetchBatch) receiver := rpc.NewGoReceiver() @@ -988,14 +1002,13 @@ func (s *server) getObjectFromJava(id string, sourceFileType string) any { // Mirrors the getObject recovery in RewriteRpc (Java) and rewrite-rpc.ts // (JS): on failure they `remoteObjects.remove(id)` and rethrow. // - // The shared ref table is intentionally left intact. Java's - // reverse-direction send refs are connection-scoped (cleared only on - // Reset), so discarding ours would make Java's bare {ref:N} look-ups - // fail with "received reference to unknown object: N" — turning a single - // failed request into a fresh cascade. + // The shared ref table is intentionally left intact. Java's send refs are + // connection-scoped (cleared only on Reset), so discarding ours would make + // Java's bare {ref:N} look-ups fail with "received reference to unknown + // object: N" — turning a single failed request into a fresh cascade. defer func() { if r := recover(); r != nil { - delete(s.reverseRemoteObjects, id) + delete(s.remoteObjects, id) panic(r) // surface as one clear error via safeHandleRequest } }() @@ -1019,7 +1032,10 @@ func (s *server) getObjectFromJava(id string, sourceFileType string) any { }() if obj != nil { - s.reverseRemoteObjects[id] = obj + // We are now in sync with Java on this object — record it as the shared + // baseline (used by both directions) and as our local copy. Mirrors + // rewrite-python get_object_from_java / RewriteRpc.getObject. + s.remoteObjects[id] = obj s.localObjects[id] = obj } @@ -1104,8 +1120,6 @@ func (s *server) handleReset() bool { s.remoteObjects = make(map[string]any) s.localRefs = make(map[uintptr]int) s.remoteRefs = make(map[int]any) - s.reverseRemoteObjects = make(map[string]any) - s.reverseRemoteRefs = make(map[int]any) s.preparedRecipes = make(map[string]recipe.Recipe) s.preparedEditorOverrides = make(map[string]recipe.TreeVisitor) s.preparedAccumulators = make(map[string]any) @@ -1692,11 +1706,13 @@ func (s *server) handleVisit(params json.RawMessage) (any, *rpcError) { // since tree nodes contain slices which are not comparable). modified := !treeIdentical(before, after) - // Store the result — update both localObjects (for forward GetObject) - // and reverseRemoteObjects (baseline for reverse getObjectFromJava in Print) + // Store the result in localObjects ONLY. Java does not have `after` yet — it + // will pull it via a forward GetObject, which diffs against the unchanged + // remoteObjects baseline (the original) to produce the correct delta. Writing + // `after` into the baseline here would make that diff a no-op and silently + // drop the recipe's edits. Mirrors rewrite-python/rewrite-csharp handle_visit. if after != nil { s.localObjects[req.TreeID] = after - s.reverseRemoteObjects[req.TreeID] = after } else { delete(s.localObjects, req.TreeID) } @@ -1881,11 +1897,10 @@ func (s *server) handleBatchVisit(params json.RawMessage) (any, *rpcError) { } } - // Store the final tree under both req.treeId and its own id (if different), - // matching the JS pattern. + // Store the final tree in localObjects only (Java pulls it via forward + // GetObject, diffing against the unchanged baseline). See handleVisit. if current != nil { s.localObjects[req.TreeID] = current - s.reverseRemoteObjects[req.TreeID] = current } return &batchVisitResponse{Results: results}, nil @@ -2019,7 +2034,10 @@ func (s *server) handleParseProject(params json.RawMessage) (any, *rpcError) { switch { case filepath.Base(path) == "go.mod": disc.goMods = append(disc.goMods, path) - case strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go"): + case strings.HasSuffix(path, ".go"): + // Include _test.go files: `go mod tidy` accounts for test-only + // imports (e.g. testify), so the LST must carry them for an + // import-graph-based tidy to avoid pruning test dependencies. disc.goFiles = append(disc.goFiles, path) } return nil @@ -2164,60 +2182,83 @@ func (s *server) handleParseProject(params json.RawMessage) (any, *rpcError) { // don't appear in the response — handled here so the post-parse // `cus` slice aligns with the `included` subset of entries. cuByIdx := make(map[int]*golang.CompilationUnit, len(disc.goFiles)) + peByIdx := make(map[int]*java.ParseError) for key, entries := range groups { p := goparser.NewGoParser() if pi, ok := piByModule[key.moduleDir]; ok { p.Importer = pi } - included := make([]fileEntry, 0, len(entries)) inputs := make([]goparser.FileInput, 0, len(entries)) + idxBySourcePath := make(map[string]int, len(entries)) for _, e := range entries { - if !goparser.MatchBuildContext(p.BuildContext, filepath.Base(e.sourcePath), e.content) { - continue - } - included = append(included, e) inputs = append(inputs, goparser.FileInput{Path: e.sourcePath, Content: e.content}) + idxBySourcePath[e.sourcePath] = e.idx } - if len(inputs) == 0 { - continue - } - cus, err := func() (out []*golang.CompilationUnit, err error) { + sfs, err := func() (out []java.Tree, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }() - return p.ParsePackage(inputs) + return p.ParsePackage(inputs), nil }() if err != nil { - for _, e := range included { + // Whole-package panic — represent every file as a ParseError so + // none silently drops to a PlainText/Quark on the Java side. + for _, e := range entries { s.logger.Printf("ParseProject: parse error in %s: %v", e.path, err) + peByIdx[e.idx] = java.NewParseError(e.sourcePath, e.content, err) } continue } - for i, cu := range cus { - cuByIdx[included[i].idx] = cu + // ParsePackage returns one SourceFile per build-included file (a CU + // or, for a file that fails to parse, a ParseError), mapped back by + // source path. Build-excluded files are absent — correctly omitted, + // since they are not part of this build. + for _, sf := range sfs { + switch v := sf.(type) { + case *golang.CompilationUnit: + if idx, ok := idxBySourcePath[v.SourcePath]; ok { + cuByIdx[idx] = v + } + case *java.ParseError: + if idx, ok := idxBySourcePath[v.SourcePath]; ok { + s.logger.Printf("ParseProject: parse error in %s: %v", v.SourcePath, v.Cause()) + peByIdx[idx] = v + } + } } } - // Emit results in input order, attaching the owning module's marker - // to each cu so Java-side recipes can read module dependency info. + // Emit results in input order. A parsed file emits a CompilationUnit + // (with the owning module's marker so Java-side recipes can read module + // dependency info); a file that failed to parse emits a ParseError so it + // round-trips losslessly instead of dropping to a PlainText/Quark on the + // Java side. Build-excluded files appear in neither map and are omitted. items := make([]parseProjectResponseItem, 0, len(disc.goFiles)) for _, o := range order { - cu, ok := cuByIdx[o.idx] - if !ok || cu == nil { + if cu, ok := cuByIdx[o.idx]; ok && cu != nil { + if o.modCtx != nil { + cu.Markers = java.AddMarker(cu.Markers, *o.modCtx.mrr) + } + id := cu.ID.String() + s.localObjects[id] = cu + items = append(items, parseProjectResponseItem{ + ID: id, + SourceFileType: "org.openrewrite.golang.tree.Go$CompilationUnit", + SourcePath: o.sourcePath, + }) continue } - if o.modCtx != nil { - cu.Markers = java.AddMarker(cu.Markers, *o.modCtx.mrr) + if pe, ok := peByIdx[o.idx]; ok { + id := pe.Ident.String() + s.localObjects[id] = pe + items = append(items, parseProjectResponseItem{ + ID: id, + SourceFileType: "org.openrewrite.tree.ParseError", + SourcePath: o.sourcePath, + }) } - id := cu.ID.String() - s.localObjects[id] = cu - items = append(items, parseProjectResponseItem{ - ID: id, - SourceFileType: "org.openrewrite.golang.tree.Go$CompilationUnit", - SourcePath: o.sourcePath, - }) } // Emit each go.mod as a lossless GoMod LST so recipes operate on the diff --git a/rewrite-go/cmd/rpc/receive_resilience_test.go b/rewrite-go/cmd/rpc/receive_resilience_test.go index 96587185650..7267039ba6b 100644 --- a/rewrite-go/cmd/rpc/receive_resilience_test.go +++ b/rewrite-go/cmd/rpc/receive_resilience_test.go @@ -75,8 +75,8 @@ func TestGetObjectFromJavaPanicResetsBaselineButKeepsRefs(t *testing.T) { // Pre-seed state a live session would hold from prior successful cycles: // - a baseline that diverges from Java's once the receive panics, and // - a shared ref the panic must NOT wipe. - s.reverseRemoteObjects[id] = "STALE-BASELINE" - s.reverseRemoteRefs[5] = "shared-value-from-earlier-transfer" + s.remoteObjects[id] = "STALE-BASELINE" + s.remoteRefs[5] = "shared-value-from-earlier-transfer" // Transfer 1: must panic mid-receive. panicked := func() (p bool) { @@ -93,12 +93,12 @@ func TestGetObjectFromJavaPanicResetsBaselineButKeepsRefs(t *testing.T) { } // Containment: the diverged per-id baseline is dropped. - if v, ok := s.reverseRemoteObjects[id]; ok { - t.Errorf("reverseRemoteObjects[%q] should be deleted after a receive panic, still present: %v", id, v) + if v, ok := s.remoteObjects[id]; ok { + t.Errorf("remoteObjects[%q] should be deleted after a receive panic, still present: %v", id, v) } // ...but the shared ref table survives. - if got := s.reverseRemoteRefs[5]; got != "shared-value-from-earlier-transfer" { - t.Errorf("reverseRemoteRefs[5] should survive a receive panic, got %v", got) + if got := s.remoteRefs[5]; got != "shared-value-from-earlier-transfer" { + t.Errorf("remoteRefs[5] should survive a receive panic, got %v", got) } // Transfer 2: a fresh request on the same server must succeed cleanly, @@ -107,7 +107,7 @@ func TestGetObjectFromJavaPanicResetsBaselineButKeepsRefs(t *testing.T) { if got != "package main\n" { t.Errorf("transfer 2: want clean ADD value %q, got %#v", "package main\n", got) } - if s.reverseRemoteObjects[id] != "package main\n" { - t.Errorf("transfer 2: baseline should be repopulated, got %#v", s.reverseRemoteObjects[id]) + if s.remoteObjects[id] != "package main\n" { + t.Errorf("transfer 2: baseline should be repopulated, got %#v", s.remoteObjects[id]) } } diff --git a/rewrite-go/pkg/parser/go_parser.go b/rewrite-go/pkg/parser/go_parser.go index 7327853fa64..4120be0fa26 100644 --- a/rewrite-go/pkg/parser/go_parser.go +++ b/rewrite-go/pkg/parser/go_parser.go @@ -20,10 +20,12 @@ import ( "fmt" "go/ast" "go/build" + "go/constant" "go/importer" "go/parser" "go/token" "go/types" + "math" "path/filepath" "reflect" "strconv" @@ -72,19 +74,51 @@ type FileInput struct { Content string } +// FileImports returns the import paths declared in a single Go source file, +// parsed imports-only. Unlike ParsePackage it ignores build constraints and +// never type-checks, so it works for any file regardless of the host platform +// — useful for unioning imports across build configurations the way +// `go mod tidy` does (e.g. reading the dependencies of a `//go:build windows` +// file on Linux, where it would otherwise be excluded from the LST). Returns +// nil on parse error. +func FileImports(content string) []string { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", content, parser.ImportsOnly) + if err != nil || f == nil { + return nil + } + out := make([]string, 0, len(f.Imports)) + for _, spec := range f.Imports { + if spec.Path == nil { + continue + } + if p, err := strconv.Unquote(spec.Path.Value); err == nil && p != "" { + out = append(out, p) + } + } + return out +} + // Parse parses a single Go source file and returns its CompilationUnit. // Convenience wrapper around ParsePackage for the common one-file case; // type attribution that depends on sibling files in the same package // won't resolve here. Use ParsePackage when sibling files matter. func (gp *GoParser) Parse(sourcePath string, source string) (*golang.CompilationUnit, error) { - cus, err := gp.ParsePackage([]FileInput{{Path: sourcePath, Content: source}}) - if err != nil { - return nil, err - } - if len(cus) == 0 { + files := gp.ParsePackage([]FileInput{{Path: sourcePath, Content: source}}) + if len(files) == 0 { return nil, fmt.Errorf("no compilation unit produced") } - return cus[0], nil + switch f := files[0].(type) { + case *golang.CompilationUnit: + return f, nil + case *java.ParseError: + if cause := f.Cause(); cause != nil { + return nil, cause + } + return nil, fmt.Errorf("parse error in %s", sourcePath) + default: + return nil, fmt.Errorf("unexpected parse result %T for %s", files[0], sourcePath) + } } // ParsePackage parses every file in a single Go package together so @@ -93,77 +127,101 @@ func (gp *GoParser) Parse(sourcePath string, source string) (*golang.Compilation // types.Info populated by one types.Config.Check call. // // All files MUST belong to the same package (same `package` clause). -// Order in the returned slice matches the input order. -func (gp *GoParser) ParsePackage(files []FileInput) ([]*golang.CompilationUnit, error) { - if len(files) == 0 { - return nil, nil - } - - // Filter out files excluded by the build context — `//go:build` / - // `// +build` constraints and OS/arch filename suffixes. Skipped - // files don't appear in the output at all (they're as if they - // weren't passed in). - filtered := make([]FileInput, 0, len(files)) - for _, f := range files { - if MatchBuildContext(gp.BuildContext, filepath.Base(f.Path), f.Content) { - filtered = append(filtered, f) - } - } - files = filtered +// +// It returns one SourceFile per build-included input, in input order: a +// *golang.CompilationUnit when the file parses, or a *java.ParseError when +// it does not. A single malformed file never takes down its siblings and +// never silently disappears — it travels as a ParseError so the Java side +// represents it as such instead of falling back to a PlainText/Quark. +// Files the build context excludes (`//go:build` / OS-arch suffix) are +// omitted: they are not part of this build, which is not a parse failure. +func (gp *GoParser) ParsePackage(files []FileInput) []java.Tree { if len(files) == 0 { - return nil, nil + return nil } fset := token.NewFileSet() + + // One outcome per build-included file, in input order: a parsed AST or + // the parse error. Build-excluded files are not represented at all. + type outcome struct { + input FileInput + ast *ast.File // nil when err != nil + err error + } + outcomes := make([]outcome, 0, len(files)) asts := make([]*ast.File, 0, len(files)) for _, f := range files { + if !MatchBuildContext(gp.BuildContext, filepath.Base(f.Path), f.Content) { + continue + } a, err := parser.ParseFile(fset, f.Path, f.Content, parser.ParseComments) if err != nil { - return nil, fmt.Errorf("parse %s: %w", f.Path, err) + outcomes = append(outcomes, outcome{input: f, err: err}) + continue } + outcomes = append(outcomes, outcome{input: f, ast: a}) asts = append(asts, a) } - - typeInfo := &types.Info{ - Types: make(map[ast.Expr]types.TypeAndValue), - Defs: make(map[*ast.Ident]types.Object), - Uses: make(map[*ast.Ident]types.Object), - Selections: make(map[*ast.SelectorExpr]*types.Selection), - // Instances records identifiers denoting generic functions/types that are - // instantiated with explicit type arguments, e.g. the `Map` in `Map[int]`. - // Used to distinguish generic instantiation from ordinary indexing. - Instances: make(map[*ast.Ident]types.Instance), - } - conf := types.Config{ - Importer: gp.Importer, - // Don't fail on type errors — we want partial type info even when - // some imports can't be resolved. - Error: func(error) {}, + if len(outcomes) == 0 { + return nil } - // Use the first file's package name as the type-checker hint; - // types.Config.Check validates that all files agree. - pkgName := "main" - if asts[0].Name != nil { - pkgName = asts[0].Name.Name + // Type-check every file that parsed, together, so cross-file references + // resolve. Skipped entirely when nothing parsed (all inputs failed). + var typeInfo *types.Info + var mapper *typeMapper + if len(asts) > 0 { + typeInfo = &types.Info{ + Types: make(map[ast.Expr]types.TypeAndValue), + Defs: make(map[*ast.Ident]types.Object), + Uses: make(map[*ast.Ident]types.Object), + Selections: make(map[*ast.SelectorExpr]*types.Selection), + // Instances records identifiers denoting generic functions/types that are + // instantiated with explicit type arguments, e.g. the `Map` in `Map[int]`. + // Used to distinguish generic instantiation from ordinary indexing. + Instances: make(map[*ast.Ident]types.Instance), + } + conf := types.Config{ + Importer: gp.Importer, + // Don't fail on type errors — we want partial type info even when + // some imports can't be resolved. + Error: func(error) {}, + } + + // Hint the package name with the first parsed file's. Internal/external + // test files (`foo` vs `foo_test`) in the same directory are + // type-checked best-effort; errors are ignored, and imports (what + // `go mod tidy` needs) are captured regardless. + pkgName := "main" + for _, o := range outcomes { + if o.ast != nil && o.ast.Name != nil { + pkgName = o.ast.Name.Name + break + } + } + _, _ = conf.Check(pkgName, fset, asts, typeInfo) + mapper = newTypeMapper() } - _, _ = conf.Check(pkgName, fset, asts, typeInfo) - mapper := newTypeMapper() - cus := make([]*golang.CompilationUnit, 0, len(files)) - for i, f := range files { + out := make([]java.Tree, 0, len(outcomes)) + for _, o := range outcomes { + if o.err != nil { + out = append(out, java.NewParseError(o.input.Path, o.input.Content, o.err)) + continue + } ctx := &parseContext{ - src: []byte(f.Content), + src: []byte(o.input.Content), fset: fset, - file: fset.File(asts[i].Pos()), - astFile: asts[i], + file: fset.File(o.ast.Pos()), + astFile: o.ast, cursor: 0, typeInfo: typeInfo, mapper: mapper, } - cus = append(cus, ctx.mapFile(asts[i], f.Path)) + out = append(out, ctx.mapFile(o.ast, o.input.Path)) } - return cus, nil + return out } // parseContext holds the state needed during AST-to-LST mapping. @@ -1901,21 +1959,89 @@ func (ctx *parseContext) mapIdent(ident *ast.Ident) *java.Identifier { return id } -// mapBasicLit maps a basic literal (string, int, float, etc.) +// mapBasicLit maps a basic literal (string, int, float, etc.). +// +// Source keeps the exact Go literal text (the printer emits Source, so output +// is byte-identical), but Value is normalized into a form org.openrewrite's +// J.Literal — a boxed Java primitive — can coerce. Go literal syntax (hex / +// octal / binary / underscore integers, quoted runes) is not what Java's +// Integer/Byte/Char.valueOf accepts, and Go's wider ranges (uint8 0..255, +// int64) overflow the Java primitive the type maps to. Left un-normalized, V3's +// coerceLiteralValue throws NumberFormatException mid-receive and desyncs the +// whole RPC stream (surfacing as unrelated downstream NPEs). func (ctx *parseContext) mapBasicLit(lit *ast.BasicLit) *java.Literal { prefix := ctx.prefix(lit.Pos()) ctx.skip(len(lit.Value)) l := &java.Literal{ID: uuid.New(), Prefix: prefix, Value: lit.Value, Source: lit.Value} - // Type attribution for literal - if tv, ok := ctx.typeInfo.Types[lit]; ok { + tv, ok := ctx.typeInfo.Types[lit] + if ok { l.Type = ctx.mapper.mapType(tv.Type) } + // Normalize numeric/rune Value from the exact constant go/types computed. + // String literals pass through unchanged (Java coerces String as-is). + if ok && tv.Value != nil { + switch tv.Value.Kind() { + case constant.Int: + l.Value, l.Type = normalizeIntLiteral(tv.Value, lit.Kind, l.Type) + case constant.Float: + f, _ := constant.Float64Val(tv.Value) + l.Value = strconv.FormatFloat(f, 'g', -1, 64) + case constant.Complex: + // Java has no complex primitive; keep the real part as a double so + // coercion doesn't choke on Go's `i` suffix. + f, _ := constant.Float64Val(constant.Real(tv.Value)) + l.Value = strconv.FormatFloat(f, 'g', -1, 64) + } + } + return l } +// normalizeIntLiteral returns a Java-parseable Value and a primitive type wide +// enough to hold it. A rune literal typed `char` becomes the character itself +// (Java Character.charAt(0)) when it fits the Basic Multilingual Plane; +// otherwise it is the numeric code point. +func normalizeIntLiteral(v constant.Value, kind token.Token, typ java.JavaType) (string, java.JavaType) { + i, exact := constant.Int64Val(v) + if !exact { + // Exceeds int64 (a large uint64): use the signed wraparound so Java's + // Long.valueOf parses it, and widen the type to long. + if u, ok := constant.Uint64Val(v); ok { + return strconv.FormatInt(int64(u), 10), &java.JavaTypePrimitive{Keyword: "long"} + } + return v.String(), typ + } + if isPrimitive(typ, "char") { + if i >= 0 && i <= 0xFFFF { + return string(rune(i)), typ + } + // Supplementary-plane rune: a Java char can't hold it — treat as int. + return strconv.FormatInt(i, 10), &java.JavaTypePrimitive{Keyword: "int"} + } + return strconv.FormatInt(i, 10), widenIntType(typ, i) +} + +// widenIntType promotes a Java primitive when the value doesn't fit it: a Go +// byte (0..255) overflows Java's signed byte, and a Go int64 overflows Java int. +func widenIntType(typ java.JavaType, i int64) java.JavaType { + if i < math.MinInt32 || i > math.MaxInt32 { + return &java.JavaTypePrimitive{Keyword: "long"} + } + if isPrimitive(typ, "byte") && (i < math.MinInt8 || i > math.MaxInt8) { + return &java.JavaTypePrimitive{Keyword: "int"} + } + return typ +} + +// isPrimitive reports whether t is the given Java primitive keyword. +func isPrimitive(t java.JavaType, keyword string) bool { + p, ok := t.(*java.JavaTypePrimitive) + return ok && p.Keyword == keyword +} + // hoistLeftPrefix detaches the leading whitespace from a node's first child // so it can be attached to the enclosing (outermost) element instead, per the // OpenRewrite convention that whitespace belongs to the outermost element. @@ -2959,7 +3085,21 @@ func (ctx *parseContext) mapStructTag(vd *java.VariableDeclarations, tag *ast.Ba } pairs := parseStructTagPairs(raw) - if len(pairs) == 0 { + if !isCanonicalStructTag(tag.Value, pairs) { + // Non-canonical tag — double-quoted (e.g. "form:\"idx\""), only + // partially parseable, or with non-gofmt inner whitespace — cannot be + // losslessly reconstructed from the decomposed `key:"value"` annotation + // form (the printer always re-emits canonical backtick-wrapped pairs). + // Store it verbatim so it round-trips exactly. Recipes get decomposed + // pairs only for canonical gofmt'd tags. + vd.Markers.Entries = append(vd.Markers.Entries, golang.StructTag{ + Ident: uuid.New(), + Tag: &java.Literal{ + ID: uuid.New(), + Prefix: outerPrefix, + Source: tag.Value, + }, + }) return } @@ -3108,6 +3248,33 @@ type structTagPair struct { UnquotedValue string // the value contents after Go-string unquoting } +// isCanonicalStructTag reports whether a struct tag literal can be losslessly +// reconstructed from the decomposed `key:"value"` annotation form. That holds +// only for backtick-delimited tags whose contents are exactly the concatenation +// of their parsed pairs — i.e. gofmt's canonical form. Double-quoted tags +// (which the printer would otherwise re-emit as backtick), tags with leading/ +// trailing or odd inner whitespace, and partially-parseable tags all fail here +// and are stored verbatim instead so they round-trip exactly. +func isCanonicalStructTag(tagValue string, pairs []structTagPair) bool { + if len(pairs) == 0 { + return false + } + if len(tagValue) < 2 || tagValue[0] != '`' || tagValue[len(tagValue)-1] != '`' { + return false + } + inner := tagValue[1 : len(tagValue)-1] + var b strings.Builder + for i, p := range pairs { + if i > 0 { + b.WriteString(p.PrefixWS) + } + b.WriteString(p.Key) + b.WriteByte(':') + b.WriteString(p.QuotedValue) + } + return b.String() == inner +} + // parseStructTagPairs scans a struct tag's contents (without the // surrounding backticks) into a sequence of `key:"value"` pairs. // Mirrors `reflect.StructTag.Lookup`'s scanning loop: diff --git a/rewrite-go/pkg/printer/go_printer.go b/rewrite-go/pkg/printer/go_printer.go index 5cd36ea61ec..8b11dd87664 100644 --- a/rewrite-go/pkg/printer/go_printer.go +++ b/rewrite-go/pkg/printer/go_printer.go @@ -480,23 +480,27 @@ func (p *GoPrinter) VisitVariableDeclarations(vd *java.VariableDeclarations, par if vd.TypeExpr != nil { p.Visit(vd.TypeExpr, out) } - // Then struct tag, reconstructed from LeadingAnnotations (one - // Annotation per `key:"value"` pair). Only emitted when this - // VariableDeclarations is a struct field — non-struct positions - // don't allow tags syntactically. Inner-leading / inner-trailing - // whitespace is normalized to gofmt's canonical zero-padding (we - // chose Option 1 in the design discussion: lossy on non-canonical - // input, exact on gofmt'd input). - if len(vd.LeadingAnnotations) > 0 && p.insideStructType() { - first := vd.LeadingAnnotations[0] - p.visitSpace(first.Prefix, out) - out.Append("`") - p.printAnnotationBody(first, out) - for _, ann := range vd.LeadingAnnotations[1:] { - p.visitSpace(ann.Prefix, out) - p.printAnnotationBody(ann, out) + // Then the struct field tag. A non-canonical tag (double-quoted, or with + // non-gofmt inner whitespace) is stored verbatim on a StructTag marker — + // emit it as-is so it round-trips exactly. Otherwise reconstruct the + // canonical backtick-wrapped `key:"value"` form from LeadingAnnotations + // (one Annotation per pair). Only emitted in struct-field position — + // non-struct positions don't allow tags syntactically. + if p.insideStructType() { + if st := java.FindMarker[golang.StructTag](vd.Markers); st != nil && st.Tag != nil { + p.visitSpace(st.Tag.Prefix, out) + out.Append(st.Tag.Source) + } else if len(vd.LeadingAnnotations) > 0 { + first := vd.LeadingAnnotations[0] + p.visitSpace(first.Prefix, out) + out.Append("`") + p.printAnnotationBody(first, out) + for _, ann := range vd.LeadingAnnotations[1:] { + p.visitSpace(ann.Prefix, out) + p.printAnnotationBody(ann, out) + } + out.Append("`") } - out.Append("`") } // Then initializers firstInit := true diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go index 9e1ca434e15..9eb518a111c 100644 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go @@ -23,6 +23,7 @@ import ( "github.com/google/uuid" + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe/golang/internal" @@ -85,19 +86,36 @@ func (r *GoModTidy) EditorWithData(acc any) recipe.TreeVisitor { // --- scan phase --- // -// NOTE: `go mod tidy` unions imports across ALL build configurations -// (every GOOS/GOARCH and build tag). This scanner only sees the imports of -// the .go files present in the LST it is given. If the project was parsed -// under a single build context (the default), platform-gated imports — e.g. -// a windows-only dependency in a `//go:build windows` file — will be absent -// and such modules can be misclassified as indirect. Full parity therefore -// requires the project to be parsed so that all-platform files are included. +// `go mod tidy` unions imports across ALL build configurations (every +// GOOS/GOARCH and build tag). The Go parser type-checks under a single host +// build context, so files excluded by that context (e.g. a `//go:build +// windows` file on Linux) are not parsed into a Go.CompilationUnit — the CLI +// represents them as PlainText instead. To avoid misclassifying their +// dependencies as unused, the scanner also reads imports out of PlainText +// `.go` files (parsed imports-only, which is platform-independent). This +// recovers platform-gated imports without the full cost/ambiguity of +// per-configuration type-checking. type goModTidyScanner struct { visitor.GoVisitor acc *tidyAcc } +// Visit intercepts PlainText source files — which the framework's Go dispatch +// has no case for — to harvest imports from build-excluded `.go` files. Every +// other tree falls through to the normal Go dispatch. +func (v *goModTidyScanner) Visit(t java.Tree, p any) java.Tree { + if pt, ok := t.(*java.PlainText); ok { + if strings.HasSuffix(pt.SourcePath, ".go") { + for _, imp := range parser.FileImports(pt.Text) { + v.acc.rawImports[imp] = true + } + } + return t + } + return v.GoVisitor.Visit(t, p) +} + func (v *goModTidyScanner) VisitCompilationUnit(cu *golang.CompilationUnit, p any) java.J { if cu.Imports != nil { for _, rp := range cu.Imports.Elements { diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go new file mode 100644 index 00000000000..9728ca9808c --- /dev/null +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go @@ -0,0 +1,62 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package golang + +import ( + "testing" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" +) + +// TestScannerHarvestsPlainTextGoImports verifies the scan phase reads imports +// out of a build-excluded `.go` file that the CLI represented as PlainText +// (e.g. a `//go:build windows` file on Linux), so go mod tidy doesn't prune a +// platform-only dependency it can no longer "see". +func TestScannerHarvestsPlainTextGoImports(t *testing.T) { + r := &GoModTidy{} + acc := r.InitialValue(nil).(*tidyAcc) + scan := r.Scanner(acc) + + scan.Visit(&java.PlainText{ + SourcePath: "internal/sys_windows.go", + Text: "//go:build windows\n\npackage sys\n\nimport (\n\t\"golang.org/x/sys/windows\"\n\t\"fmt\"\n)\n", + }, nil) + + if !acc.rawImports["golang.org/x/sys/windows"] { + t.Errorf("expected windows-only import to be harvested from PlainText; got %v", acc.rawImports) + } + if !acc.rawImports["fmt"] { + t.Errorf("expected fmt import to be harvested from PlainText; got %v", acc.rawImports) + } +} + +// TestScannerIgnoresNonGoPlainText verifies non-.go PlainText files (README, +// go.sum, …) are left alone — only `.go` text is parsed for imports. +func TestScannerIgnoresNonGoPlainText(t *testing.T) { + r := &GoModTidy{} + acc := r.InitialValue(nil).(*tidyAcc) + scan := r.Scanner(acc) + + scan.Visit(&java.PlainText{ + SourcePath: "README.md", + Text: "import \"this is not go\"\n", + }, nil) + + if len(acc.rawImports) != 0 { + t.Errorf("expected no imports harvested from a non-.go PlainText; got %v", acc.rawImports) + } +} diff --git a/rewrite-go/pkg/rpc/go_receiver.go b/rewrite-go/pkg/rpc/go_receiver.go index c93a2b6021f..3ec72003328 100644 --- a/rewrite-go/pkg/rpc/go_receiver.go +++ b/rewrite-go/pkg/rpc/go_receiver.go @@ -54,6 +54,10 @@ func (r *GoReceiver) Visit(t java.Tree, p any) java.Tree { c := *pe return r.receiveParseError(&c, p.(*ReceiveQueue)) } + if pt, ok := t.(*java.PlainText); ok { + c := *pt + return r.receivePlainText(&c, p.(*ReceiveQueue)) + } if gm, ok := t.(*golang.GoMod); ok { c := *gm return receiveGoMod(&c, p.(*ReceiveQueue)) @@ -76,7 +80,17 @@ func (r *GoReceiver) receiveParseError(pe *java.ParseError, q *ReceiveQueue) *ja pe.Ident = parsed } } - pe.Markers = receiveMarkersCodec(q, pe.Markers) + // markers is a nested object: consume its envelope via q.Receive, then read + // sub-fields in the onChange (matches JavaReceiver.PreVisit / receivePlainText). + // A direct receiveMarkersCodec call skips the envelope and desyncs the queue + // on any marker-bearing ParseError. + if result := q.Receive(pe.Markers, func(v any) any { + return receiveMarkersCodec(q, v.(java.Markers)) + }); result != nil { + if mk, ok := result.(java.Markers); ok { + pe.Markers = mk + } + } pe.SourcePath = receiveScalar[string](q, pe.SourcePath) pe.CharsetName = receiveScalar[string](q, pe.CharsetName) pe.CharsetBomMarked = receiveScalar[bool](q, pe.CharsetBomMarked) @@ -86,6 +100,65 @@ func (r *GoReceiver) receiveParseError(pe *java.ParseError, q *ReceiveQueue) *ja return pe } +// receivePlainText deserializes a PlainText matching the canonical field order +// in org.openrewrite.text.PlainTextRpcCodec (and rewrite-javascript's +// text/rpc.ts): id, markers, sourcePath, charsetName, charsetBomMarked, +// checksum, fileAttributes, text, snippets. The Go side only reads SourcePath +// and Text; checksum / fileAttributes / each snippet's fields are consumed and +// discarded so the wire stays in lockstep. +func (r *GoReceiver) receivePlainText(pt *java.PlainText, q *ReceiveQueue) *java.PlainText { + idStr := receiveScalar[string](q, pt.Ident.String()) + if idStr != "" { + if parsed, err := uuid.Parse(idStr); err == nil { + pt.Ident = parsed + } + } + // markers: a nested object — consume its envelope via q.Receive, then read + // its sub-fields in the onChange, mirroring JavaReceiver.PreVisit. Calling + // receiveMarkersCodec directly (as receiveParseError does) skips the + // envelope message and desyncs the queue on any real, marker-bearing file. + if result := q.Receive(pt.Markers, func(v any) any { + return receiveMarkersCodec(q, v.(java.Markers)) + }); result != nil { + if mk, ok := result.(java.Markers); ok { + pt.Markers = mk + } + } + pt.SourcePath = receiveScalar[string](q, pt.SourcePath) + pt.CharsetName = receiveScalar[string](q, pt.CharsetName) + pt.CharsetBomMarked = receiveScalar[bool](q, pt.CharsetBomMarked) + // checksum — Checksum.rpcSend sends algorithm (string) + value (byte[]); + // nullable, so the onChange only runs when present (matches VisitCompilationUnit). + q.Receive(nil, func(v any) any { + receiveScalar[string](q, "") // algorithm + q.Receive(nil, nil) // value + return nil + }) + // fileAttributes — FileAttributes.rpcSend sends 7 sub-fields (3 timestamps, + // 3 bools, size); populated on files read from disk. The timestamps are + // java.time.ZonedDateTime leaves (see value_types factories). + q.Receive(nil, func(v any) any { + q.Receive(nil, nil) // creationTime + q.Receive(nil, nil) // lastModifiedTime + q.Receive(nil, nil) // lastAccessTime + q.Receive(nil, nil) // isReadable + q.Receive(nil, nil) // isWritable + q.Receive(nil, nil) // isExecutable + q.Receive(nil, nil) // size + return nil + }) + pt.Text = receiveScalar[string](q, pt.Text) + // snippets: a list of {id, markers, text}; empty for files. Consume each + // element's fields to keep the queue aligned if a recipe ever produced one. + q.ReceiveList(nil, func(v any) any { + q.Receive(nil, nil) // snippet id + q.Receive(nil, nil) // snippet markers + q.Receive(nil, nil) // snippet text + return v + }) + return pt +} + func (r *GoReceiver) VisitCompilationUnit(cu *golang.CompilationUnit, p any) java.J { q := p.(*ReceiveQueue) c := *cu // shallow copy to avoid mutating remoteObjects baseline diff --git a/rewrite-go/pkg/rpc/go_unary_rpc_test.go b/rewrite-go/pkg/rpc/go_unary_rpc_test.go new file mode 100644 index 00000000000..d8bf8260cc0 --- /dev/null +++ b/rewrite-go/pkg/rpc/go_unary_rpc_test.go @@ -0,0 +1,55 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rpc + +import ( + "testing" + + "github.com/google/uuid" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" +) + +// A Go unary `&x` / `*x` / `<-x` operator must survive the RPC round-trip. If +// the operator enum is lost it decodes to 0 (invalid) and the printer emits the +// unknown-operator placeholder "?", corrupting emitted patches (e.g. `&T{}` → +// `?T{}`). +func TestGoUnaryRoundTrip_OperatorPreserved(t *testing.T) { + for _, tc := range []struct { + name string + op golang.UnaryOperator + }{ + {"AddressOf", golang.AddressOf}, + {"Indirection", golang.Indirection}, + {"Receive", golang.Receive}, + } { + t.Run(tc.name, func(t *testing.T) { + id := uuid.New() + before := &golang.Unary{ + ID: id, + Operator: java.LeftPadded[golang.UnaryOperator]{Element: tc.op, Markers: java.Markers{}}, + Expression: makeIdent("x"), + } + seed := &golang.Unary{ID: id} + got := roundTripNode(t, before, seed).(*golang.Unary) + if got.Operator.Element != tc.op { + t.Errorf("Operator: want %v (%d), got %v (%d)", tc.op, tc.op, got.Operator.Element, got.Operator.Element) + } + }) + } +} diff --git a/rewrite-go/pkg/rpc/java_receiver.go b/rewrite-go/pkg/rpc/java_receiver.go index 5cf647266e6..a0222c8f07d 100644 --- a/rewrite-go/pkg/rpc/java_receiver.go +++ b/rewrite-go/pkg/rpc/java_receiver.go @@ -584,8 +584,14 @@ func (r *JavaReceiver) VisitSwitch(sw *java.Switch, p any) java.J { q := p.(*ReceiveQueue) c := *sw // shallow copy to avoid mutating remoteObjects baseline sw = &c - // selector - Java sends ControlParentheses, extract inner Expression for Tag - if cpResult := q.Receive(nil, func(v any) any { return r.Visit(v.(java.Tree), q) }); cpResult != nil { + // selector - Java sends ControlParentheses, extract inner Expression for Tag. + // Pass the baseline wrapped as the sender wraps it so a CHANGE delta resolves + // the tag expression's NO_CHANGE inner spaces against the baseline. + var selBefore any + if sw.Tag != nil { + selBefore = &java.ControlParentheses{Tree: java.RightPadded[java.Expression]{Element: sw.Tag.Element, After: java.EmptySpace}} + } + if cpResult := q.Receive(selBefore, func(v any) any { return r.Visit(v.(java.Tree), q) }); cpResult != nil { if cp, ok := cpResult.(*java.ControlParentheses); ok { if _, isEmpty := cp.Tree.Element.(*java.Empty); !isEmpty { sw.Tag = &java.RightPadded[java.Expression]{ @@ -605,8 +611,11 @@ func (r *JavaReceiver) VisitCase(cs *java.Case, p any) java.J { cs = &c q.Receive(nil, nil) // type enum cs.Expressions = receiveContainer[java.Expression](r, q, cs.Expressions) - // statements - Java sends Container>, extract to Go's []RightPadded[Statement] - if result := q.Receive(nil, func(v any) any { return receiveContainerTyped[java.Statement](r, q, v) }); result != nil { + // statements - Java sends Container>, extract to Go's + // []RightPadded[Statement]. Pass the baseline container so a CHANGE delta + // resolves the statements' NO_CHANGE inner spaces against the baseline. + stmtsBefore := java.Container[java.Statement]{Elements: cs.Body} + if result := q.Receive(stmtsBefore, func(v any) any { return receiveContainerTyped[java.Statement](r, q, v) }); result != nil { cont := result.(java.Container[java.Statement]) cs.Body = cont.Elements } diff --git a/rewrite-go/pkg/rpc/left_padded_operator_rpc_test.go b/rewrite-go/pkg/rpc/left_padded_operator_rpc_test.go index 60692bf9251..e6f3975e261 100644 --- a/rewrite-go/pkg/rpc/left_padded_operator_rpc_test.go +++ b/rewrite-go/pkg/rpc/left_padded_operator_rpc_test.go @@ -37,19 +37,28 @@ import ( func TestCoerceLeftPaddedEnum_AmbiguousNameResolvesByParser(t *testing.T) { // "Addition" is valid for BOTH operator enums; the supplied parser decides which. - asAssign := coerceLeftPaddedEnum(java.EmptySpace, "Addition", java.Markers{}, java.ParseAssignmentOperator) + asAssign := coerceLeftPaddedEnum(java.EmptySpace, "Addition", java.Markers{}, java.ParseAssignmentOperator, java.AssignmentOperator(0)) if asAssign.Element != java.AddAssign { t.Errorf("ParseAssignmentOperator: want AddAssign, got %v", asAssign.Element) } - asBinary := coerceLeftPaddedEnum(java.EmptySpace, "Addition", java.Markers{}, java.ParseBinaryOperator) + asBinary := coerceLeftPaddedEnum(java.EmptySpace, "Addition", java.Markers{}, java.ParseBinaryOperator, java.BinaryOperator(0)) if asBinary.Element != java.Add { t.Errorf("ParseBinaryOperator: want Add, got %v", asBinary.Element) } } +func TestCoerceLeftPaddedEnum_AbsentElementFallsBackToBaseline(t *testing.T) { + // A NO_CHANGE delta against a diverged baseline yields neither a typed enum + // nor a wire string; the baseline enum must be preserved (not zeroed → "?"). + got := coerceLeftPaddedEnum(java.EmptySpace, nil, java.Markers{}, java.ParseBinaryOperator, java.Add) + if got.Element != java.Add { + t.Errorf("absent element: want baseline Add, got %v", got.Element) + } +} + func TestCoerceLeftPaddedEnum_PreTypedEnumPassThrough(t *testing.T) { // NO_CHANGE hands back the already-typed enum; it must survive. - got := coerceLeftPaddedEnum(java.EmptySpace, java.OrAssign, java.Markers{}, java.ParseAssignmentOperator) + got := coerceLeftPaddedEnum(java.EmptySpace, java.OrAssign, java.Markers{}, java.ParseAssignmentOperator, java.AssignmentOperator(0)) if got.Element != java.OrAssign { t.Errorf("pre-typed pass-through: want OrAssign, got %v", got.Element) } diff --git a/rewrite-go/pkg/rpc/padding_rpc.go b/rewrite-go/pkg/rpc/padding_rpc.go index dfbee3a5fcf..3a7b63e6226 100644 --- a/rewrite-go/pkg/rpc/padding_rpc.go +++ b/rewrite-go/pkg/rpc/padding_rpc.go @@ -173,7 +173,7 @@ func receiveLeftPadded(r Receiver, q *ReceiveQueue, before any) any { func receiveLeftPaddedEnum[T any](r Receiver, q *ReceiveQueue, before java.LeftPadded[T], parse func(string) T) java.LeftPadded[T] { result := q.Receive(before, func(v any) any { beforeSpace, elem, markers := receiveLeftPaddedParts(r, q, v) - return coerceLeftPaddedEnum(beforeSpace, elem, markers, parse) + return coerceLeftPaddedEnum(beforeSpace, elem, markers, parse, before.Element) }) if result == nil { return before @@ -184,14 +184,19 @@ func receiveLeftPaddedEnum[T any](r Receiver, q *ReceiveQueue, before java.LeftP // coerceLeftPaddedEnum builds a LeftPadded[T] for an enum slot. The element is either // already a T (NO_CHANGE pass-through / pre-typed enum) or the enum's Java // enum-constant name as a string, which `parse` resolves to the T constant. -func coerceLeftPaddedEnum[T any](before java.Space, elem any, m java.Markers, parse func(string) T) java.LeftPadded[T] { +// +// beforeElem is the receiver's baseline enum. When the element is absent — a +// NO_CHANGE delta resolved against a baseline that diverged on an earlier +// transport — we fall back to it rather than the zero value, which a Go-specific +// operator (golang.Unary/Binary.Type) would otherwise print as "?". +func coerceLeftPaddedEnum[T any](before java.Space, elem any, m java.Markers, parse func(string) T, beforeElem T) java.LeftPadded[T] { if e, ok := elem.(T); ok { return java.LeftPadded[T]{Before: before, Element: e, Markers: m} } if s, ok := elem.(string); ok { return java.LeftPadded[T]{Before: before, Element: parse(s), Markers: m} } - return java.LeftPadded[T]{Before: before, Markers: m} + return java.LeftPadded[T]{Before: before, Element: beforeElem, Markers: m} } // Accessor functions for generic padding types (using type switches for Go's type-parameterized structs) @@ -316,7 +321,15 @@ func coerceToExpressionRP(rp any) java.RightPadded[java.Expression] { if expr, ok := elem.(java.Expression); ok { return java.RightPadded[java.Expression]{Element: expr, After: after, Markers: m} } - panic(fmt.Sprintf("coerceToExpressionRP: element does not implement java.Expression (rp=%T elem=%T nil=%v)", rp, elem, elem == nil)) + if elem == nil { + // A nil element is an RPC NO_CHANGE delta resolved against a diverged + // receiver baseline, not real corruption: degrade to an element-less + // padding instead of panicking. A panic here drops the whole file and + // poisons the batch stream (cascading into unrelated trees); a dropped + // element is recoverable. A non-nil wrong type IS a real bug → still panic. + return java.RightPadded[java.Expression]{After: after, Markers: m} + } + panic(fmt.Sprintf("coerceToExpressionRP: element does not implement java.Expression (rp=%T elem=%T)", rp, elem)) } // coerceToStatementRP converts a RightPadded of any variant to RightPadded[Statement]. @@ -331,7 +344,12 @@ func coerceToStatementRP(rp any) java.RightPadded[java.Statement] { if stmt, ok := elem.(java.Statement); ok { return java.RightPadded[java.Statement]{Element: stmt, After: after, Markers: m} } - panic(fmt.Sprintf("coerceToStatementRP: element does not implement java.Statement (rp=%T elem=%T nil=%v)", rp, elem, elem == nil)) + if elem == nil { + // Nil element = RPC NO_CHANGE against a diverged baseline (see + // coerceToExpressionRP); degrade rather than crash the whole file. + return java.RightPadded[java.Statement]{After: after, Markers: m} + } + panic(fmt.Sprintf("coerceToStatementRP: element does not implement java.Statement (rp=%T elem=%T)", rp, elem)) } // coerceLeftPaddedIdent converts a LeftPadded of any variant to LeftPadded[*Identifier]. diff --git a/rewrite-go/pkg/rpc/print_collapse_repro_test.go b/rewrite-go/pkg/rpc/print_collapse_repro_test.go new file mode 100644 index 00000000000..90e584315a2 --- /dev/null +++ b/rewrite-go/pkg/rpc/print_collapse_repro_test.go @@ -0,0 +1,138 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rpc + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" +) + +// loadBatchOfMaps converts a JSON array of message-maps into one RpcObjectData batch. +func loadBatchOfMaps(t *testing.T, raw []any) []RpcObjectData { + t.Helper() + batch := make([]RpcObjectData, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + t.Fatalf("batch item is not a map: %T", item) + } + batch = append(batch, ParseObjectData(m)) + } + return batch +} + +// TestPrintWireCollapsesUnchangedSubtree replays the EXACT wire captured from a +// real moderne-cli run of RenameXToFlag on: +// +// func Bind(x int) int { +// value := x +// if value == 1 { // <- untouched by the recipe +// value = 2 +// } +// fmt.Println(value, x) +// return value +// } +// +// `print_collapse_baseline.json` is Go's reconstruction baseline at print time +// (verified PERFECT in the capture: baselineCollapsed=false). +// `print_collapse_wire.json` is the GetObject delta Java's GolangSender sent to +// Go to print the edited tree. +// +// Applying that delta to the perfect baseline via GoReceiver reproduces the +// end-to-end bug: the untouched `if` block collapses to `if value == 1{value=2}`. +func TestPrintWireCollapsesUnchangedSubtree(t *testing.T) { + // --- reconstruct the baseline (full ADD wire) --- + var baselineRaw []any + b, err := os.ReadFile("testdata/print_collapse_baseline.json") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &baselineRaw); err != nil { + t.Fatal(err) + } + + refs := make(map[int]any) + delivered := false + baselineBatch := loadBatchOfMaps(t, baselineRaw) + bq := NewReceiveQueue(refs, func() []RpcObjectData { + if delivered { + return nil + } + delivered = true + return baselineBatch + }) + brcv := NewGoReceiver() + baseline := bq.Receive(nil, func(v any) any { + if t, ok := v.(java.Tree); ok { + return brcv.Visit(t, bq) + } + return v + }) + baseCU, ok := baseline.(*golang.CompilationUnit) + if !ok { + t.Fatalf("baseline did not reconstruct to a CompilationUnit: %T", baseline) + } + baseSrc := printer.Print(baseCU) + if strings.Contains(baseSrc, "1{") { + t.Fatalf("precondition failed: baseline is already collapsed:\n%s", baseSrc) + } + if !strings.Contains(baseSrc, "if value == 1 {") { + t.Fatalf("precondition failed: baseline does not contain the expected if block:\n%s", baseSrc) + } + + // --- replay Java's print delta against the perfect baseline --- + var wireBatches []any + w, err := os.ReadFile("testdata/print_collapse_wire.json") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(w, &wireBatches); err != nil { + t.Fatal(err) + } + bi := 0 + pq := NewReceiveQueue(refs, func() []RpcObjectData { + if bi >= len(wireBatches) { + return nil + } + raw := wireBatches[bi].([]any) + bi++ + return loadBatchOfMaps(t, raw) + }) + prcv := NewGoReceiver() + result := pq.Receive(baseCU, func(v any) any { + if t, ok := v.(java.Tree); ok { + return prcv.Visit(t, pq) + } + return v + }) + resCU, ok := result.(*golang.CompilationUnit) + if !ok { + t.Fatalf("result did not reconstruct to a CompilationUnit: %T", result) + } + resSrc := printer.Print(resCU) + + // The bug: the unchanged if block loses ALL its whitespace. + if !strings.Contains(resSrc, "if value == 1 {\n\t\tvalue = 2\n\t}") { + t.Errorf("REPRODUCED: print wire collapsed the unchanged if block.\n--- baseline (perfect) ---\n%s\n--- result (collapsed) ---\n%s", baseSrc, resSrc) + } +} diff --git a/rewrite-go/pkg/rpc/space_rpc.go b/rewrite-go/pkg/rpc/space_rpc.go index 0d6e981eca3..857ea63af3a 100644 --- a/rewrite-go/pkg/rpc/space_rpc.go +++ b/rewrite-go/pkg/rpc/space_rpc.go @@ -536,10 +536,11 @@ func receiveMarkersCodec(q *ReceiveQueue, before java.Markers) java.Markers { } }) var entries []java.Marker - if afterAny != nil { - entries = make([]java.Marker, len(afterAny)) - for i, v := range afterAny { - entries[i] = v.(java.Marker) + for _, v := range afterAny { + // Skip a nil/non-Marker entry — an RPC NO_CHANGE delta resolved against + // a diverged baseline — instead of a panicking type assertion. + if m, ok := v.(java.Marker); ok { + entries = append(entries, m) } } return java.Markers{ID: id, Entries: entries} diff --git a/rewrite-go/pkg/rpc/testdata/print_collapse_baseline.json b/rewrite-go/pkg/rpc/testdata/print_collapse_baseline.json new file mode 100644 index 00000000000..42d12a87f7d --- /dev/null +++ b/rewrite-go/pkg/rpc/testdata/print_collapse_baseline.json @@ -0,0 +1 @@ +[{"state":"ADD","valueType":"org.openrewrite.golang.tree.Go$CompilationUnit"},{"state":"ADD","value":"ea6b314b-58de-4274-9cc0-a050ce4bbd63"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[-1,-1,-1,-1,-1,-1]},{"state":"ADD","valueType":"org.openrewrite.golang.marker.GoResolutionResult"},{"state":"ADD","value":"9f981511-c6c4-4220-87bc-4c150c7ae907"},{"state":"ADD","value":"example.com/wsprobe"},{"state":"ADD","value":"1.22"},{"state":"NO_CHANGE"},{"state":"ADD","value":"/home/sam/code/workspace/go-mod-tidy/tmp/wsbuild/wsprobe/go.mod"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","valueType":"org.openrewrite.golang.marker.GoResolutionResult$GoModule"},{"state":"ADD","value":"example.com/wsprobe"},{"state":"ADD","value":""},{"state":"ADD","value":"1.22"},{"state":"ADD","value":true},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":true},{"state":"ADD","valueType":"org.openrewrite.marker.LstProvenance","value":{"@ref":1,"buildToolType":"Cli","buildToolVersion":"0.1.0-SNAPSHOT","id":"d0eb220b-3a6c-4461-b1e9-043044335c90","lstSerializerVersion":"0.1.0-SNAPSHOT","timestampUtc":1781926555.6793594}},{"state":"ADD","valueType":"org.openrewrite.marker.OperatingSystemProvenance$Linux","value":{"@ref":2,"id":"6cad70e6-c90d-4771-b900-4ea144dd052e","nativePrefix":"linux-amd64","osName":"Linux","osVersion":"7.0.0-22-generic","toStringValue":"Linux 7.0.0-22-generic amd64"}},{"state":"ADD","valueType":"org.openrewrite.marker.BuildTool","value":{"@ref":3,"id":"f0d4bdde-0ce4-42a0-b268-90b88a8d2da3","type":"ModerneCli","version":"0.1.0-SNAPSHOT"}},{"state":"ADD","valueType":"org.openrewrite.marker.GitProvenance","value":{"@ref":4,"autocrlf":"False","branch":"main","change":"b6a04604c8f45df8b0ca71c0783ab033572d09fa","committers":[{"dates":[20623,1],"email":"t@t","name":"t"}],"eol":"Native","gitRemote":{"organization":"example","origin":"github.com","path":"example/wsprobe","repositoryName":"wsprobe","service":"GitHub","url":"https://github.com/example/wsprobe.git"},"id":"de3785f5-03e2-44ef-8806-d5f0659b9863","origin":"https://github.com/example/wsprobe.git"}},{"state":"ADD","valueType":"org.openrewrite.marker.GitTreeEntry","value":{"@ref":1,"fileMode":33188,"id":"a23ad8fc-6218-4711-a23d-9f9310c22426","objectId":"7fe220057d25d8a38e701545f2796d1b5118300b"}},{"state":"ADD","value":"main.go"},{"state":"ADD","value":"UTF-8"},{"state":"ADD","value":false},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"15a29822-54fd-4f44-8d0e-a1aa728d941e"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"main"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JContainer"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"\n\n"},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Import"},{"state":"ADD","value":"0b011141-04bd-4d41-8224-e83aa0273b6e"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JLeftPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","value":false},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Literal"},{"state":"ADD","value":"e01fbe8c-e648-4946-9096-cf9fd0fd0e70"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"fmt"},{"state":"ADD","value":"\"fmt\""},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Primitive","ref":1},{"state":"ADD","value":"String"},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$MethodDeclaration"},{"state":"ADD","value":"3f0f8b94-63f4-4cb4-bdc8-007e6f07d7c1"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"\n\n"},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"a3fd958a-b4b8-4c27-a2dc-31c4c8dd16b9"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"int"},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Primitive","ref":2},{"state":"ADD","value":"int"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"f6939bea-1015-40a2-82b5-28e172d21a84"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"Bind"},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Method","ref":3},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Class","ref":4},{"state":"ADD","value":0},{"state":"ADD","value":"Class"},{"state":"ADD","value":"main"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"Bind"},{"state":"ADD","value":1},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","value":"x"},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.JContainer"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$VariableDeclarations"},{"state":"ADD","value":"946db98f-20b4-48b4-abd9-575a4f0826b5"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"b74a87a5-c011-446a-b6a8-fe21074f9f93"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"int"},{"state":"ADD","ref":2},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$VariableDeclarations$NamedVariable"},{"state":"ADD","value":"3c1debbe-6a83-42b3-85b9-4de62481541b"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"5bb09170-735e-4f76-a6b7-510e379a465a"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"x"},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Variable","ref":5},{"state":"ADD","value":"x"},{"state":"NO_CHANGE"},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Block"},{"state":"ADD","value":"296f225b-4403-4965-a588-c5a7be96dabd"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","value":false},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[-1,-1,-1,-1]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Assignment"},{"state":"ADD","value":"e3f4839c-3807-4928-b41e-6fabb7ae1944"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"\n\t"},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"48cc0f61-a6bc-4692-82f9-f305b3b992c0"},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","valueType":"org.openrewrite.golang.marker.ShortVarDecl"},{"state":"ADD","value":"4fc64617-9e11-4407-81b4-32f8d6bd681c"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"511c7414-6e98-4738-b46b-6450dfa94565"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"value"},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Variable","ref":6},{"state":"ADD","value":"value"},{"state":"NO_CHANGE"},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JLeftPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"55fb583c-4d14-4727-a09a-12cfedcda42a"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"x"},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Variable","ref":7},{"state":"ADD","value":"x"},{"state":"NO_CHANGE"},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$If"},{"state":"ADD","value":"ad875f10-cae5-419a-9ca5-a9d598d23a5a"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"\n\t"},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$ControlParentheses"},{"state":"ADD","value":"5b12fff7-b253-428b-8ab7-7d4ca1a1411b"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"387d3f03-2e1e-41a9-bc65-15674f00e076"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Binary"},{"state":"ADD","value":"5786c002-0bbb-4c78-a90a-e788f35bd767"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"2f9f3853-3a8f-4e73-b1b2-dbcb0a308f18"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"value"},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Variable","ref":8},{"state":"ADD","value":"value"},{"state":"NO_CHANGE"},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JLeftPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","value":"Equal"},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Literal"},{"state":"ADD","value":"d99e7031-f01c-4b7b-990e-af4d2d983458"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":1},{"state":"ADD","value":"1"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Primitive","ref":9},{"state":"ADD","value":"boolean"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Block"},{"state":"ADD","value":"47493ca0-833f-4521-8538-9a74be185f9a"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","value":false},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Assignment"},{"state":"ADD","value":"215671fe-a643-46b5-9476-9b64b7572d74"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"\n\t\t"},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"c6f56829-2ac0-4ea2-99cd-0d1d4be60f13"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"value"},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Variable","ref":10},{"state":"ADD","value":"value"},{"state":"NO_CHANGE"},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JLeftPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Literal"},{"state":"ADD","value":"b94ba845-babc-4448-83dd-404fdb1db097"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":2},{"state":"ADD","value":"2"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"\n\t"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$MethodInvocation"},{"state":"ADD","value":"5c073fe2-daa3-4c1d-b649-95093a123067"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"\n\t"},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"288525ef-e8ce-4248-999e-7b94527a5dbc"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"fmt"},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Class","ref":11},{"state":"ADD","value":0},{"state":"ADD","value":"Class"},{"state":"ADD","value":"fmt"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"3bc232b0-7882-4cca-b2fc-df7b7d4da877"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"Println"},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Method","ref":12},{"state":"ADD","ref":11},{"state":"ADD","value":"Println"},{"state":"ADD","value":1},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Parameterized","ref":13},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Class","ref":14},{"state":"ADD","value":0},{"state":"ADD","value":"Class"},{"state":"ADD","value":"go.tuple"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[-1,-1]},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Class","ref":15},{"state":"ADD","value":2},{"state":"ADD","value":"Interface"},{"state":"ADD","value":"error"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Method","ref":16},{"state":"NO_CHANGE"},{"state":"ADD","value":"Error"},{"state":"ADD","value":1},{"state":"ADD","ref":1},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","value":"a"},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Array","ref":17},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Unknown","ref":18},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.JContainer"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD"},{"state":"CHANGE","value":[-1,-1]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"74a46027-c5e3-4891-b87f-cd2d67bd6f56"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"value"},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Variable","ref":19},{"state":"ADD","value":"value"},{"state":"NO_CHANGE"},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"0b92b659-396a-4941-bd0a-e3f2b419e674"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"x"},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Variable","ref":20},{"state":"ADD","value":"x"},{"state":"NO_CHANGE"},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","ref":12},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Return"},{"state":"ADD","value":"dedb14a5-4f26-44d5-8034-2e042166626c"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"\n\t"},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"ADD","value":"829da381-c9c8-4652-b3b3-8d97f2f0044d"},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":" "},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"value"},{"state":"ADD","ref":2},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Variable","ref":21},{"state":"ADD","value":"value"},{"state":"NO_CHANGE"},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"\n"},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.java.tree.JavaType$Method","ref":22},{"state":"ADD","ref":4},{"state":"ADD","value":"Bind"},{"state":"ADD","value":1},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","value":"x"},{"state":"ADD"},{"state":"CHANGE","value":[-1]},{"state":"ADD","ref":2},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"NO_CHANGE"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":""},{"state":"ADD","valueType":"org.openrewrite.marker.Markers"},{"state":"ADD","value":"00000000-0000-0000-0000-000000000000"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","valueType":"org.openrewrite.java.tree.Space"},{"state":"ADD"},{"state":"CHANGE","value":[]},{"state":"ADD","value":"\n"},{"state":"END_OF_OBJECT"}] \ No newline at end of file diff --git a/rewrite-go/pkg/rpc/testdata/print_collapse_wire.json b/rewrite-go/pkg/rpc/testdata/print_collapse_wire.json new file mode 100644 index 00000000000..3fc83559f9a --- /dev/null +++ b/rewrite-go/pkg/rpc/testdata/print_collapse_wire.json @@ -0,0 +1 @@ +[[{"state":"CHANGE","valueType":"org.openrewrite.golang.tree.Go$CompilationUnit"},{"state":"CHANGE","value":"ea6b314b-58de-4274-9cc0-a050ce4bbd63"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"CHANGE"},{"state":"CHANGE","value":[0,1,2,3,4,5,-1]},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"ADD","valueType":"org.openrewrite.marker.RecipesThatMadeChanges","value":{"@ref":1,"id":"a8c3b49b-d7ed-420c-8dd6-c02656db8d62","recipes":[[{"@c":"io.moderne.serialization.v3.marker.RecipesThatMadeChangesSlimModule$Stub","@c":"io.moderne.serialization.v3.marker.RecipesThatMadeChangesSlimModule$Stub","tags":[],"name":"org.openrewrite.golang.test.RenameXToFlag"}]]},"ref":32},{"state":"CHANGE","value":"main.go"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"15a29822-54fd-4f44-8d0e-a1aa728d941e"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"main"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JContainer"},{"state":"NO_CHANGE"},{"state":"CHANGE"},{"state":"CHANGE","value":[0]},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Import"},{"state":"CHANGE","value":"0b011141-04bd-4d41-8224-e83aa0273b6e"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JLeftPadded"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Literal"},{"state":"CHANGE","value":"e01fbe8c-e648-4946-9096-cf9fd0fd0e70"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"fmt"},{"state":"CHANGE","value":"\"fmt\""},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE"},{"state":"CHANGE","value":[0]},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$MethodDeclaration"},{"state":"CHANGE","value":"3f0f8b94-63f4-4cb4-bdc8-007e6f07d7c1"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"a3fd958a-b4b8-4c27-a2dc-31c4c8dd16b9"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"int"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"f6939bea-1015-40a2-82b5-28e172d21a84"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"Bind"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JContainer"},{"state":"NO_CHANGE"},{"state":"CHANGE"},{"state":"CHANGE","value":[0]},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$VariableDeclarations"},{"state":"CHANGE","value":"946db98f-20b4-48b4-abd9-575a4f0826b5"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"b74a87a5-c011-446a-b6a8-fe21074f9f93"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"int"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE"},{"state":"CHANGE","value":[0]},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$VariableDeclarations$NamedVariable"},{"state":"CHANGE","value":"3c1debbe-6a83-42b3-85b9-4de62481541b"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"5bb09170-735e-4f76-a6b7-510e379a465a"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"flag"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Block"},{"state":"CHANGE","value":"296f225b-4403-4965-a588-c5a7be96dabd"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE"},{"state":"CHANGE","value":[0,1,2,3]},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Assignment"},{"state":"CHANGE","value":"e3f4839c-3807-4928-b41e-6fabb7ae1944"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"48cc0f61-a6bc-4692-82f9-f305b3b992c0"},{"state":"CHANGE"},{"state":"CHANGE","value":[0]},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"511c7414-6e98-4738-b46b-6450dfa94565"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"value"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JLeftPadded"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"55fb583c-4d14-4727-a09a-12cfedcda42a"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"flag"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$If"},{"state":"CHANGE","value":"ad875f10-cae5-419a-9ca5-a9d598d23a5a"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$ControlParentheses"},{"state":"CHANGE","value":"5b12fff7-b253-428b-8ab7-7d4ca1a1411b"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"387d3f03-2e1e-41a9-bc65-15674f00e076"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Binary"},{"state":"CHANGE","value":"5786c002-0bbb-4c78-a90a-e788f35bd767"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"2f9f3853-3a8f-4e73-b1b2-dbcb0a308f18"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"value"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JLeftPadded"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Literal"},{"state":"CHANGE","value":"d99e7031-f01c-4b7b-990e-af4d2d983458"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"1"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Block"},{"state":"CHANGE","value":"47493ca0-833f-4521-8538-9a74be185f9a"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE"},{"state":"CHANGE","value":[0]},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Assignment"},{"state":"CHANGE","value":"215671fe-a643-46b5-9476-9b64b7572d74"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"c6f56829-2ac0-4ea2-99cd-0d1d4be60f13"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"value"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JLeftPadded"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Literal"},{"state":"CHANGE","value":"b94ba845-babc-4448-83dd-404fdb1db097"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"2"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$MethodInvocation"},{"state":"CHANGE","value":"5c073fe2-daa3-4c1d-b649-95093a123067"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"288525ef-e8ce-4248-999e-7b94527a5dbc"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"fmt"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"3bc232b0-7882-4cca-b2fc-df7b7d4da877"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"Println"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JContainer"},{"state":"NO_CHANGE"},{"state":"CHANGE"},{"state":"CHANGE","value":[0,1]},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"74a46027-c5e3-4891-b87f-cd2d67bd6f56"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"value"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"0b92b659-396a-4941-bd0a-e3f2b419e674"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"flag"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.JRightPadded"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Return"},{"state":"CHANGE","value":"dedb14a5-4f26-44d5-8034-2e042166626c"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.java.tree.J$Identifier"},{"state":"CHANGE","value":"829da381-c9c8-4652-b3b3-8d97f2f0044d"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","value":"value"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"CHANGE","valueType":"org.openrewrite.marker.Markers"},{"state":"CHANGE","value":"00000000-0000-0000-0000-000000000000"},{"state":"NO_CHANGE"},{"state":"NO_CHANGE"},{"state":"END_OF_OBJECT"}]] \ No newline at end of file diff --git a/rewrite-go/pkg/rpc/value_types.go b/rewrite-go/pkg/rpc/value_types.go index 90a42d0fb96..ebc3d6d2f72 100644 --- a/rewrite-go/pkg/rpc/value_types.go +++ b/rewrite-go/pkg/rpc/value_types.go @@ -227,6 +227,20 @@ func init() { RegisterFactory("org.openrewrite.tree.ParseError", func() any { return &java.ParseError{Ident: uuid.New()} }) RegisterFactory("org.openrewrite.ParseExceptionResult", func() any { return java.ParseExceptionResult{} }) + // PlainText — received only (the CLI's Go build step backfills one for any + // .go file the parser doesn't return); GoModTidy reads its imports. + RegisterFactory("org.openrewrite.text.PlainText", func() any { return &java.PlainText{Ident: uuid.New()} }) + + // java.time.* leaf values appear inside FileAttributes (creation / + // lastModified / lastAccess times) on PlainText files read from disk. We + // discard FileAttributes, but the receiver still instantiates each leaf via + // newObj before discarding its value — register benign factories so an + // otherwise-unknown type doesn't panic mid-receive. + RegisterFactory("java.time.ZonedDateTime", func() any { return "" }) + RegisterFactory("java.time.Instant", func() any { return "" }) + RegisterFactory("java.time.LocalDateTime", func() any { return "" }) + RegisterFactory("java.time.OffsetDateTime", func() any { return "" }) + // SourceFile-level types that implement RpcCodec RegisterFactory("org.openrewrite.Checksum", func() any { return java.GenericMarker{JavaType: "org.openrewrite.Checksum"} }) RegisterFactory("org.openrewrite.FileAttributes", func() any { return java.GenericMarker{JavaType: "org.openrewrite.FileAttributes"} }) diff --git a/rewrite-go/pkg/test/spec.go b/rewrite-go/pkg/test/spec.go index a68e24d16fe..d6d2769c9e1 100644 --- a/rewrite-go/pkg/test/spec.go +++ b/rewrite-go/pkg/test/spec.go @@ -253,26 +253,26 @@ func parsePackageGroups(t *testing.T, p *parser.GoParser, flat []SourceSpec) map out := map[int]*golang.CompilationUnit{} for dir, group := range byDir { - // Pre-filter against BuildContext so post-parse `cus` aligns - // with the included subset of `group`. - included := make([]indexed, 0, len(group)) files := make([]parser.FileInput, 0, len(group)) for _, g := range group { - if !parser.MatchBuildContext(p.BuildContext, path.Base(g.input.Path), g.input.Content) { - continue - } - included = append(included, g) files = append(files, g.input) } - if len(files) == 0 { - continue - } - cus, err := p.ParsePackage(files) - if err != nil { - t.Fatalf("parse error in package %s: %v", dir, err) - } - for i, cu := range cus { - out[included[i].idx] = cu + idxByPath := make(map[string]int, len(group)) + for _, g := range group { + idxByPath[g.input.Path] = g.idx + } + // ParsePackage returns one SourceFile per build-included input, + // mapped back by source path; a parse failure arrives as a + // ParseError, which a test never expects, so fail loudly. + for _, sf := range p.ParsePackage(files) { + switch v := sf.(type) { + case *golang.CompilationUnit: + if idx, ok := idxByPath[v.SourcePath]; ok { + out[idx] = v + } + case *java.ParseError: + t.Fatalf("parse error in package %s (%s): %v", dir, v.SourcePath, v.Cause()) + } } } return out diff --git a/rewrite-go/pkg/tree/golang/go.go b/rewrite-go/pkg/tree/golang/go.go index aac1e397dbe..99b89da0a72 100644 --- a/rewrite-go/pkg/tree/golang/go.go +++ b/rewrite-go/pkg/tree/golang/go.go @@ -41,36 +41,54 @@ func (*CompilationUnit) IsSourceFile() {} func (n *CompilationUnit) GetSourcePath() string { return n.SourcePath } func (n *CompilationUnit) WithPrefix(prefix java.Space) *CompilationUnit { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *CompilationUnit) WithMarkers(markers java.Markers) *CompilationUnit { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *CompilationUnit) WithStatements(statements []java.RightPadded[java.Statement]) *CompilationUnit { + if java.SameSlice(n.Statements, statements) { + return n + } c := *n c.Statements = statements return &c } func (n *CompilationUnit) WithPackageDecl(pkg *java.RightPadded[*java.Identifier]) *CompilationUnit { + if n.PackageDecl == pkg { + return n + } c := *n c.PackageDecl = pkg return &c } func (n *CompilationUnit) WithImports(imports *java.Container[*java.Import]) *CompilationUnit { + if n.Imports == imports { + return n + } c := *n c.Imports = imports return &c } func (n *CompilationUnit) WithEOF(eof java.Space) *CompilationUnit { + if java.SpaceEqual(n.EOF, eof) { + return n + } c := *n c.EOF = eof return &c @@ -89,12 +107,18 @@ func (*GoStmt) IsJ() {} func (*GoStmt) IsStatement() {} func (n *GoStmt) WithPrefix(prefix java.Space) *GoStmt { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *GoStmt) WithMarkers(markers java.Markers) *GoStmt { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -113,12 +137,18 @@ func (*Defer) IsJ() {} func (*Defer) IsStatement() {} func (n *Defer) WithPrefix(prefix java.Space) *Defer { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Defer) WithMarkers(markers java.Markers) *Defer { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -138,12 +168,18 @@ func (*Send) IsJ() {} func (*Send) IsStatement() {} func (n *Send) WithPrefix(prefix java.Space) *Send { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Send) WithMarkers(markers java.Markers) *Send { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -162,12 +198,18 @@ func (*Goto) IsJ() {} func (*Goto) IsStatement() {} func (n *Goto) WithPrefix(prefix java.Space) *Goto { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Goto) WithMarkers(markers java.Markers) *Goto { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -185,12 +227,18 @@ func (*Fallthrough) IsJ() {} func (*Fallthrough) IsStatement() {} func (n *Fallthrough) WithPrefix(prefix java.Space) *Fallthrough { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Fallthrough) WithMarkers(markers java.Markers) *Fallthrough { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -210,12 +258,18 @@ func (*Composite) IsJ() {} func (*Composite) IsExpression() {} func (n *Composite) WithPrefix(prefix java.Space) *Composite { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Composite) WithMarkers(markers java.Markers) *Composite { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -235,12 +289,18 @@ func (*KeyValue) IsJ() {} func (*KeyValue) IsExpression() {} func (n *KeyValue) WithPrefix(prefix java.Space) *KeyValue { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *KeyValue) WithMarkers(markers java.Markers) *KeyValue { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -264,12 +324,18 @@ func (*Slice) IsJ() {} func (*Slice) IsExpression() {} func (n *Slice) WithPrefix(prefix java.Space) *Slice { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Slice) WithMarkers(markers java.Markers) *Slice { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -292,12 +358,18 @@ func (*ArrayType) IsJ() {} func (*ArrayType) IsExpression() {} func (n *ArrayType) WithPrefix(prefix java.Space) *ArrayType { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *ArrayType) WithMarkers(markers java.Markers) *ArrayType { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -318,12 +390,18 @@ func (*MapType) IsJ() {} func (*MapType) IsExpression() {} func (n *MapType) WithPrefix(prefix java.Space) *MapType { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *MapType) WithMarkers(markers java.Markers) *MapType { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -351,12 +429,18 @@ func (*PointerType) IsJ() {} func (*PointerType) IsExpression() {} func (n *PointerType) WithPrefix(prefix java.Space) *PointerType { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *PointerType) WithMarkers(markers java.Markers) *PointerType { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -376,12 +460,18 @@ func (*Channel) IsJ() {} func (*Channel) IsExpression() {} func (n *Channel) WithPrefix(prefix java.Space) *Channel { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Channel) WithMarkers(markers java.Markers) *Channel { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -401,12 +491,18 @@ func (*FuncType) IsJ() {} func (*FuncType) IsExpression() {} func (n *FuncType) WithPrefix(prefix java.Space) *FuncType { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *FuncType) WithMarkers(markers java.Markers) *FuncType { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -425,12 +521,18 @@ func (*StructType) IsJ() {} func (*StructType) IsExpression() {} func (n *StructType) WithPrefix(prefix java.Space) *StructType { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *StructType) WithMarkers(markers java.Markers) *StructType { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -449,12 +551,18 @@ func (*InterfaceType) IsJ() {} func (*InterfaceType) IsExpression() {} func (n *InterfaceType) WithPrefix(prefix java.Space) *InterfaceType { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *InterfaceType) WithMarkers(markers java.Markers) *InterfaceType { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -475,12 +583,18 @@ func (*TypeList) IsJ() {} func (*TypeList) IsExpression() {} func (n *TypeList) WithPrefix(prefix java.Space) *TypeList { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *TypeList) WithMarkers(markers java.Markers) *TypeList { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -505,12 +619,18 @@ func (*Union) IsJ() {} func (*Union) IsExpression() {} func (n *Union) WithPrefix(prefix java.Space) *Union { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Union) WithMarkers(markers java.Markers) *Union { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -534,12 +654,18 @@ func (*UnderlyingType) IsJ() {} func (*UnderlyingType) IsExpression() {} func (n *UnderlyingType) WithPrefix(prefix java.Space) *UnderlyingType { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *UnderlyingType) WithMarkers(markers java.Markers) *UnderlyingType { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -565,24 +691,36 @@ func (*TypeDecl) IsJ() {} func (*TypeDecl) IsStatement() {} func (n *TypeDecl) WithPrefix(prefix java.Space) *TypeDecl { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *TypeDecl) WithMarkers(markers java.Markers) *TypeDecl { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *TypeDecl) WithLeadingAnnotations(anns []*java.Annotation) *TypeDecl { + if java.SameSlice(n.LeadingAnnotations, anns) { + return n + } c := *n c.LeadingAnnotations = anns return &c } func (n *TypeDecl) WithTypeParameters(tps *java.TypeParameters) *TypeDecl { + if n.TypeParameters == tps { + return n + } c := *n c.TypeParameters = tps return &c @@ -618,18 +756,27 @@ func (*DeclarationBlock) IsJ() {} func (*DeclarationBlock) IsStatement() {} func (n *DeclarationBlock) WithPrefix(prefix java.Space) *DeclarationBlock { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *DeclarationBlock) WithMarkers(markers java.Markers) *DeclarationBlock { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *DeclarationBlock) WithLeadingAnnotations(anns []*java.Annotation) *DeclarationBlock { + if java.SameSlice(n.LeadingAnnotations, anns) { + return n + } c := *n c.LeadingAnnotations = anns return &c @@ -690,12 +837,18 @@ func (*MultiAssignment) IsJ() {} func (*MultiAssignment) IsStatement() {} func (n *MultiAssignment) WithPrefix(prefix java.Space) *MultiAssignment { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *MultiAssignment) WithMarkers(markers java.Markers) *MultiAssignment { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -717,12 +870,18 @@ func (*Return) IsJ() {} func (*Return) IsStatement() {} func (n *Return) WithPrefix(prefix java.Space) *Return { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Return) WithMarkers(markers java.Markers) *Return { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -748,12 +907,18 @@ func (*MethodDeclaration) IsJ() {} func (*MethodDeclaration) IsStatement() {} func (n *MethodDeclaration) WithPrefix(prefix java.Space) *MethodDeclaration { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *MethodDeclaration) WithMarkers(markers java.Markers) *MethodDeclaration { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -780,12 +945,18 @@ func (*StatementWithInit) IsJ() {} func (*StatementWithInit) IsStatement() {} func (n *StatementWithInit) WithPrefix(prefix java.Space) *StatementWithInit { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *StatementWithInit) WithMarkers(markers java.Markers) *StatementWithInit { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -807,12 +978,18 @@ func (*CommClause) IsJ() {} func (*CommClause) IsStatement() {} func (n *CommClause) WithPrefix(prefix java.Space) *CommClause { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *CommClause) WithMarkers(markers java.Markers) *CommClause { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -833,12 +1010,18 @@ func (*StatementExpression) IsJ() {} func (*StatementExpression) IsExpression() {} func (n *StatementExpression) WithPrefix(prefix java.Space) *StatementExpression { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *StatementExpression) WithMarkers(markers java.Markers) *StatementExpression { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -859,12 +1042,18 @@ func (*IndexList) IsJ() {} func (*IndexList) IsExpression() {} func (n *IndexList) WithPrefix(prefix java.Space) *IndexList { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *IndexList) WithMarkers(markers java.Markers) *IndexList { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -926,12 +1115,18 @@ func (*Unary) IsExpression() {} func (*Unary) IsStatement() {} func (n *Unary) WithPrefix(prefix java.Space) *Unary { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Unary) WithMarkers(markers java.Markers) *Unary { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -980,12 +1175,18 @@ func (*Binary) IsJ() {} func (*Binary) IsExpression() {} func (n *Binary) WithPrefix(prefix java.Space) *Binary { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Binary) WithMarkers(markers java.Markers) *Binary { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -1035,12 +1236,18 @@ func (*AssignmentOperation) IsExpression() {} func (*AssignmentOperation) IsStatement() {} func (n *AssignmentOperation) WithPrefix(prefix java.Space) *AssignmentOperation { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *AssignmentOperation) WithMarkers(markers java.Markers) *AssignmentOperation { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -1063,12 +1270,18 @@ func (*Variadic) IsJ() {} func (*Variadic) IsExpression() {} func (n *Variadic) WithPrefix(prefix java.Space) *Variadic { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Variadic) WithMarkers(markers java.Markers) *Variadic { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c diff --git a/rewrite-go/pkg/tree/golang/gomod.go b/rewrite-go/pkg/tree/golang/gomod.go index 6dee00a9a34..bd1af374498 100644 --- a/rewrite-go/pkg/tree/golang/gomod.go +++ b/rewrite-go/pkg/tree/golang/gomod.go @@ -54,24 +54,36 @@ func (*GoMod) IsSourceFile() {} func (n *GoMod) GetSourcePath() string { return n.SourcePath } func (n *GoMod) WithPrefix(prefix java.Space) *GoMod { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *GoMod) WithMarkers(markers java.Markers) *GoMod { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *GoMod) WithStatements(statements []java.RightPadded[GoModStatement]) *GoMod { + if java.SameSlice(n.Statements, statements) { + return n + } c := *n c.Statements = statements return &c } func (n *GoMod) WithEof(eof java.Space) *GoMod { + if java.SpaceEqual(n.Eof, eof) { + return n + } c := *n c.Eof = eof return &c @@ -108,18 +120,27 @@ func (*GoModDirective) IsTree() {} func (*GoModDirective) isGoModStatement() {} func (n *GoModDirective) WithPrefix(prefix java.Space) *GoModDirective { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *GoModDirective) WithMarkers(markers java.Markers) *GoModDirective { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *GoModDirective) WithValues(values []*GoModValue) *GoModDirective { + if java.SameSlice(n.Values, values) { + return n + } c := *n c.Values = values return &c @@ -145,18 +166,27 @@ func (*GoModBlock) IsTree() {} func (*GoModBlock) isGoModStatement() {} func (n *GoModBlock) WithPrefix(prefix java.Space) *GoModBlock { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *GoModBlock) WithMarkers(markers java.Markers) *GoModBlock { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *GoModBlock) WithEntries(entries []java.RightPadded[GoModStatement]) *GoModBlock { + if java.SameSlice(n.Entries, entries) { + return n + } c := *n c.Entries = entries return &c @@ -175,18 +205,27 @@ type GoModValue struct { func (*GoModValue) IsTree() {} func (n *GoModValue) WithPrefix(prefix java.Space) *GoModValue { + if java.SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *GoModValue) WithMarkers(markers java.Markers) *GoModValue { + if java.MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *GoModValue) WithText(text string) *GoModValue { + if n.Text == text { + return n + } c := *n c.Text = text return &c diff --git a/rewrite-go/pkg/tree/java/equality.go b/rewrite-go/pkg/tree/java/equality.go new file mode 100644 index 00000000000..9a02845a3a3 --- /dev/null +++ b/rewrite-go/pkg/tree/java/equality.go @@ -0,0 +1,68 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package java + +// These helpers back the OpenRewrite identity invariant: a withX method must +// return the receiver unchanged when the new value equals the current one, so +// that visiting a subtree that nothing changed yields the SAME pointer — which +// is how the engine detects "no change" (and keeps untouched files out of a +// recipe's results/patch). Go's value types (Space, Markers, slices, the padded +// wrappers) are not ==-comparable, so withX uses these instead of a bare ==. +// +// All of these are O(1) in the unchanged case: the visitor returns the same +// slice / element pointer when nothing changed, so equality bottoms out in +// header/pointer comparisons rather than deep traversal. + +// SameSlice reports whether a and b are the same slice — same backing array and +// length. (Go slices aren't ==-comparable.) +func SameSlice[T any](a, b []T) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + return &a[0] == &b[0] +} + +// SpaceEqual reports whether two Spaces are unchanged: equal whitespace and the +// same comments slice (the visitor preserves the comments slice when unchanged). +func SpaceEqual(a, b Space) bool { + return a.Whitespace == b.Whitespace && SameSlice(a.Comments, b.Comments) +} + +// MarkersEqual reports whether two Markers are unchanged: same ID and the same +// entries slice. +func MarkersEqual(a, b Markers) bool { + return a.ID == b.ID && SameSlice(a.Entries, b.Entries) +} + +// LeftPaddedEqual reports whether two LeftPadded values are unchanged. +func LeftPaddedEqual[T comparable](a, b LeftPadded[T]) bool { + return a.Element == b.Element && SpaceEqual(a.Before, b.Before) && MarkersEqual(a.Markers, b.Markers) +} + +// RightPaddedEqual reports whether two RightPadded values are unchanged. +func RightPaddedEqual[T comparable](a, b RightPadded[T]) bool { + return a.Element == b.Element && SpaceEqual(a.After, b.After) && MarkersEqual(a.Markers, b.Markers) +} + +// ContainerEqual reports whether two Containers are unchanged: same before +// space, markers, and the same elements slice. +func ContainerEqual[T any](a, b Container[T]) bool { + return SpaceEqual(a.Before, b.Before) && MarkersEqual(a.Markers, b.Markers) && SameSlice(a.Elements, b.Elements) +} diff --git a/rewrite-go/pkg/tree/java/j.go b/rewrite-go/pkg/tree/java/j.go index dc2db42a007..a4484436579 100644 --- a/rewrite-go/pkg/tree/java/j.go +++ b/rewrite-go/pkg/tree/java/j.go @@ -34,12 +34,18 @@ func (*Identifier) IsJ() {} func (*Identifier) IsExpression() {} func (n *Identifier) WithPrefix(prefix Space) *Identifier { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Identifier) WithMarkers(markers Markers) *Identifier { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -55,12 +61,18 @@ func (n *Identifier) WithName(name string) *Identifier { } func (n *Identifier) WithType(t JavaType) *Identifier { + if n.Type == t { + return n + } c := *n c.Type = t return &c } func (n *Identifier) WithFieldType(ft *JavaTypeVariable) *Identifier { + if n.FieldType == ft { + return n + } c := *n c.FieldType = ft return &c @@ -91,18 +103,27 @@ func (*Literal) IsJ() {} func (*Literal) IsExpression() {} func (n *Literal) WithPrefix(prefix Space) *Literal { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Literal) WithMarkers(markers Markers) *Literal { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *Literal) WithValue(value any) *Literal { + if n.Value == value { + return n + } c := *n c.Value = value return &c @@ -250,24 +271,36 @@ func (*Binary) IsJ() {} func (*Binary) IsExpression() {} func (n *Binary) WithPrefix(prefix Space) *Binary { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Binary) WithMarkers(markers Markers) *Binary { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *Binary) WithLeft(left Expression) *Binary { + if n.Left == left { + return n + } c := *n c.Left = left return &c } func (n *Binary) WithRight(right Expression) *Binary { + if n.Right == right { + return n + } c := *n c.Right = right return &c @@ -287,24 +320,36 @@ func (*Block) IsJ() {} func (*Block) IsStatement() {} func (n *Block) WithPrefix(prefix Space) *Block { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Block) WithMarkers(markers Markers) *Block { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *Block) WithStatements(statements []RightPadded[Statement]) *Block { + if SameSlice(n.Statements, statements) { + return n + } c := *n c.Statements = statements return &c } func (n *Block) WithEnd(end Space) *Block { + if SpaceEqual(n.End, end) { + return n + } c := *n c.End = end return &c @@ -325,12 +370,18 @@ func (*Return) IsJ() {} func (*Return) IsStatement() {} func (n *Return) WithPrefix(prefix Space) *Return { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Return) WithMarkers(markers Markers) *Return { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -356,24 +407,36 @@ func (*If) IsJ() {} func (*If) IsStatement() {} func (n *If) WithPrefix(prefix Space) *If { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *If) WithMarkers(markers Markers) *If { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *If) WithCondition(condition *ControlParentheses) *If { + if n.Condition == condition { + return n + } c := *n c.Condition = condition return &c } func (n *If) WithThen(then *Block) *If { + if n.Then == then { + return n + } c := *n c.Then = then return &c @@ -392,12 +455,18 @@ func (*Else) IsTree() {} func (*Else) IsJ() {} func (n *Else) WithPrefix(prefix Space) *Else { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Else) WithMarkers(markers Markers) *Else { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -419,18 +488,27 @@ func (*Assignment) IsStatement() {} func (*Assignment) IsExpression() {} func (n *Assignment) WithPrefix(prefix Space) *Assignment { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Assignment) WithMarkers(markers Markers) *Assignment { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *Assignment) WithVariable(variable Expression) *Assignment { + if n.Variable == variable { + return n + } c := *n c.Variable = variable return &c @@ -527,18 +605,27 @@ func (*AssignmentOperation) IsStatement() {} func (*AssignmentOperation) IsExpression() {} func (n *AssignmentOperation) WithPrefix(prefix Space) *AssignmentOperation { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *AssignmentOperation) WithMarkers(markers Markers) *AssignmentOperation { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *AssignmentOperation) WithVariable(variable Expression) *AssignmentOperation { + if n.Variable == variable { + return n + } c := *n c.Variable = variable return &c @@ -564,36 +651,54 @@ func (*MethodDeclaration) IsStatement() {} func (*MethodDeclaration) IsExpression() {} // for function literals func (n *MethodDeclaration) WithPrefix(prefix Space) *MethodDeclaration { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *MethodDeclaration) WithMarkers(markers Markers) *MethodDeclaration { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *MethodDeclaration) WithLeadingAnnotations(anns []*Annotation) *MethodDeclaration { + if SameSlice(n.LeadingAnnotations, anns) { + return n + } c := *n c.LeadingAnnotations = anns return &c } func (n *MethodDeclaration) WithName(name *Identifier) *MethodDeclaration { + if n.Name == name { + return n + } c := *n c.Name = name return &c } func (n *MethodDeclaration) WithBody(body *Block) *MethodDeclaration { + if n.Body == body { + return n + } c := *n c.Body = body return &c } func (n *MethodDeclaration) WithTypeParameters(tps *TypeParameters) *MethodDeclaration { + if n.TypeParameters == tps { + return n + } c := *n c.TypeParameters = tps return &c @@ -617,12 +722,18 @@ func (*TypeParameters) IsTree() {} func (*TypeParameters) IsJ() {} func (n *TypeParameters) WithPrefix(prefix Space) *TypeParameters { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *TypeParameters) WithMarkers(markers Markers) *TypeParameters { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -646,12 +757,18 @@ func (*TypeParameter) IsTree() {} func (*TypeParameter) IsJ() {} func (n *TypeParameter) WithPrefix(prefix Space) *TypeParameter { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *TypeParameter) WithMarkers(markers Markers) *TypeParameter { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -672,18 +789,27 @@ func (*ForLoop) IsJ() {} func (*ForLoop) IsStatement() {} func (n *ForLoop) WithPrefix(prefix Space) *ForLoop { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *ForLoop) WithMarkers(markers Markers) *ForLoop { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *ForLoop) WithBody(body *Block) *ForLoop { + if n.Body == body { + return n + } c := *n c.Body = body return &c @@ -707,12 +833,18 @@ func (*ForControl) IsTree() {} func (*ForControl) IsJ() {} func (n *ForControl) WithPrefix(prefix Space) *ForControl { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *ForControl) WithMarkers(markers Markers) *ForControl { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -732,18 +864,27 @@ func (*ForEachLoop) IsJ() {} func (*ForEachLoop) IsStatement() {} func (n *ForEachLoop) WithPrefix(prefix Space) *ForEachLoop { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *ForEachLoop) WithMarkers(markers Markers) *ForEachLoop { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *ForEachLoop) WithBody(body *Block) *ForEachLoop { + if n.Body == body { + return n + } c := *n c.Body = body return &c @@ -771,12 +912,18 @@ func (*ForEachControl) IsTree() {} func (*ForEachControl) IsJ() {} func (n *ForEachControl) WithPrefix(prefix Space) *ForEachControl { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *ForEachControl) WithMarkers(markers Markers) *ForEachControl { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -798,18 +945,27 @@ func (*Switch) IsJ() {} func (*Switch) IsStatement() {} func (n *Switch) WithPrefix(prefix Space) *Switch { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Switch) WithMarkers(markers Markers) *Switch { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *Switch) WithBody(body *Block) *Switch { + if n.Body == body { + return n + } c := *n c.Body = body return &c @@ -829,12 +985,18 @@ func (*Case) IsJ() {} func (*Case) IsStatement() {} func (n *Case) WithPrefix(prefix Space) *Case { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Case) WithMarkers(markers Markers) *Case { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -853,12 +1015,18 @@ func (*Break) IsJ() {} func (*Break) IsStatement() {} func (n *Break) WithPrefix(prefix Space) *Break { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Break) WithMarkers(markers Markers) *Break { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -877,12 +1045,18 @@ func (*Continue) IsJ() {} func (*Continue) IsStatement() {} func (n *Continue) WithPrefix(prefix Space) *Continue { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Continue) WithMarkers(markers Markers) *Continue { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -902,12 +1076,18 @@ func (*Label) IsJ() {} func (*Label) IsStatement() {} func (n *Label) WithPrefix(prefix Space) *Label { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Label) WithMarkers(markers Markers) *Label { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -952,24 +1132,36 @@ func (*Annotation) IsJ() {} func (*Annotation) IsExpression() {} func (n *Annotation) WithPrefix(prefix Space) *Annotation { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Annotation) WithMarkers(markers Markers) *Annotation { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *Annotation) WithAnnotationType(annotationType Expression) *Annotation { + if n.AnnotationType == annotationType { + return n + } c := *n c.AnnotationType = annotationType return &c } func (n *Annotation) WithArguments(arguments *Container[Expression]) *Annotation { + if n.Arguments == arguments { + return n + } c := *n c.Arguments = arguments return &c @@ -988,6 +1180,9 @@ func (*Empty) IsStatement() {} func (*Empty) IsExpression() {} func (n *Empty) WithPrefix(prefix Space) *Empty { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c @@ -1094,18 +1289,27 @@ func (*Unary) IsExpression() {} func (*Unary) IsStatement() {} func (n *Unary) WithPrefix(prefix Space) *Unary { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Unary) WithMarkers(markers Markers) *Unary { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *Unary) WithOperand(operand Expression) *Unary { + if n.Operand == operand { + return n + } c := *n c.Operand = operand return &c @@ -1126,18 +1330,27 @@ func (*FieldAccess) IsJ() {} func (*FieldAccess) IsExpression() {} func (n *FieldAccess) WithPrefix(prefix Space) *FieldAccess { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *FieldAccess) WithMarkers(markers Markers) *FieldAccess { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *FieldAccess) WithTarget(target Expression) *FieldAccess { + if n.Target == target { + return n + } c := *n c.Target = target return &c @@ -1161,24 +1374,36 @@ func (*MethodInvocation) IsExpression() {} func (*MethodInvocation) IsStatement() {} func (n *MethodInvocation) WithPrefix(prefix Space) *MethodInvocation { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *MethodInvocation) WithMarkers(markers Markers) *MethodInvocation { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *MethodInvocation) WithName(name *Identifier) *MethodInvocation { + if n.Name == name { + return n + } c := *n c.Name = name return &c } func (n *MethodInvocation) WithTypeParameters(typeParameters *Container[Expression]) *MethodInvocation { + if n.TypeParameters == typeParameters { + return n + } c := *n c.TypeParameters = typeParameters return &c @@ -1202,18 +1427,27 @@ func (*VariableDeclarations) IsJ() {} func (*VariableDeclarations) IsStatement() {} func (n *VariableDeclarations) WithPrefix(prefix Space) *VariableDeclarations { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *VariableDeclarations) WithMarkers(markers Markers) *VariableDeclarations { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *VariableDeclarations) WithLeadingAnnotations(anns []*Annotation) *VariableDeclarations { + if SameSlice(n.LeadingAnnotations, anns) { + return n + } c := *n c.LeadingAnnotations = anns return &c @@ -1232,18 +1466,27 @@ func (*VariableDeclarator) IsTree() {} func (*VariableDeclarator) IsJ() {} func (n *VariableDeclarator) WithPrefix(prefix Space) *VariableDeclarator { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *VariableDeclarator) WithMarkers(markers Markers) *VariableDeclarator { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c } func (n *VariableDeclarator) WithName(name *Identifier) *VariableDeclarator { + if n.Name == name { + return n + } c := *n c.Name = name return &c @@ -1266,12 +1509,18 @@ func (*ArrayType) IsJ() {} func (*ArrayType) IsExpression() {} func (n *ArrayType) WithPrefix(prefix Space) *ArrayType { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *ArrayType) WithMarkers(markers Markers) *ArrayType { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -1290,12 +1539,18 @@ func (*Parentheses) IsJ() {} func (*Parentheses) IsExpression() {} func (n *Parentheses) WithPrefix(prefix Space) *Parentheses { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Parentheses) WithMarkers(markers Markers) *Parentheses { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -1315,12 +1570,18 @@ func (*TypeCast) IsJ() {} func (*TypeCast) IsExpression() {} func (n *TypeCast) WithPrefix(prefix Space) *TypeCast { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *TypeCast) WithMarkers(markers Markers) *TypeCast { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -1339,12 +1600,18 @@ func (*ControlParentheses) IsJ() {} func (*ControlParentheses) IsExpression() {} func (n *ControlParentheses) WithPrefix(prefix Space) *ControlParentheses { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *ControlParentheses) WithMarkers(markers Markers) *ControlParentheses { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -1373,12 +1640,18 @@ func (*ArrayAccess) IsJ() {} func (*ArrayAccess) IsExpression() {} func (n *ArrayAccess) WithPrefix(prefix Space) *ArrayAccess { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *ArrayAccess) WithMarkers(markers Markers) *ArrayAccess { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -1388,12 +1661,18 @@ func (*ArrayDimension) IsTree() {} func (*ArrayDimension) IsJ() {} func (n *ArrayDimension) WithPrefix(prefix Space) *ArrayDimension { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *ArrayDimension) WithMarkers(markers Markers) *ArrayDimension { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -1414,12 +1693,18 @@ func (*ParameterizedType) IsJ() {} func (*ParameterizedType) IsExpression() {} func (n *ParameterizedType) WithPrefix(prefix Space) *ParameterizedType { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *ParameterizedType) WithMarkers(markers Markers) *ParameterizedType { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c @@ -1438,12 +1723,18 @@ func (*Import) IsTree() {} func (*Import) IsJ() {} func (n *Import) WithPrefix(prefix Space) *Import { + if SpaceEqual(n.Prefix, prefix) { + return n + } c := *n c.Prefix = prefix return &c } func (n *Import) WithMarkers(markers Markers) *Import { + if MarkersEqual(n.Markers, markers) { + return n + } c := *n c.Markers = markers return &c diff --git a/rewrite-go/pkg/tree/java/parse_error.go b/rewrite-go/pkg/tree/java/parse_error.go index b0aafda3094..2ac337b34c1 100644 --- a/rewrite-go/pkg/tree/java/parse_error.go +++ b/rewrite-go/pkg/tree/java/parse_error.go @@ -16,7 +16,11 @@ package java -import "github.com/google/uuid" +import ( + "errors" + + "github.com/google/uuid" +) // ParseError represents a source file that failed to parse. // Mirrors org.openrewrite.tree.ParseError on the Java side. @@ -38,6 +42,19 @@ type ParseError struct { // dispatch. func (*ParseError) IsTree() {} +// Cause reconstructs the parse error from the ParseExceptionResult marker +// (the message captured by NewParseError), or nil when none is present. +// Lets callers that need a Go error — e.g. the single-file Parse wrapper — +// recover it without threading the original error alongside the tree. +func (pe *ParseError) Cause() error { + for _, m := range pe.Markers.Entries { + if per, ok := m.(ParseExceptionResult); ok { + return errors.New(per.Message) + } + } + return nil +} + // NewParseError creates a ParseError from a source path, source text, and error. func NewParseError(sourcePath string, source string, err error) *ParseError { marker := ParseExceptionResult{ diff --git a/rewrite-go/pkg/tree/java/plain_text.go b/rewrite-go/pkg/tree/java/plain_text.go new file mode 100644 index 00000000000..623b8ee8f3d --- /dev/null +++ b/rewrite-go/pkg/tree/java/plain_text.go @@ -0,0 +1,43 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package java + +import "github.com/google/uuid" + +// PlainText mirrors org.openrewrite.text.PlainText — the LST fallback for a +// file no language parser claimed. The Go side only ever *receives* one (the +// Moderne CLI's Go build step backfills a PlainText for any `.go` file the +// parser doesn't return, e.g. a file excluded by the host build context), so +// this type carries just enough to read its source: a Go recipe that wants +// the imports of build-excluded files scans Text. It is never produced or +// sent by the Go side. +// +// Fields not needed Go-side (checksum, fileAttributes, snippets) are consumed +// and discarded by the receive codec rather than modeled here. +type PlainText struct { + Ident uuid.UUID + Markers Markers + SourcePath string + CharsetName string + CharsetBomMarked bool + Text string +} + +// PlainText flows through the same Tree-typed Visit pipeline as a SourceFile +// alternate. Like ParseError it isn't a J node; RPC receivers and visitors +// special-case it ahead of the J dispatch. +func (*PlainText) IsTree() {} diff --git a/rewrite-go/pkg/visitor/go_visitor.go b/rewrite-go/pkg/visitor/go_visitor.go index 99e13794401..137744e62d0 100644 --- a/rewrite-go/pkg/visitor/go_visitor.go +++ b/rewrite-go/pkg/visitor/go_visitor.go @@ -373,24 +373,47 @@ var _ VisitorI = (*GoVisitor)(nil) func (v *GoVisitor) PreVisit(t java.Tree, p any) java.Tree { return t } func (v *GoVisitor) VisitCompilationUnit(cu *golang.CompilationUnit, p any) java.J { - cu = cu.WithPrefix(v.self().VisitSpace(cu.Prefix, p)) - cu = cu.WithMarkers(v.visitMarkers(cu.Markers, p)) - if cu.PackageDecl != nil { - pkg := *cu.PackageDecl - pkg.Element = visitAndCast[*java.Identifier](v, pkg.Element, p) - pkg.After = v.self().VisitSpace(pkg.After, p) - cu = cu.WithPackageDecl(&pkg) - } - if cu.Imports != nil { - imports := *cu.Imports - imports.Before = v.self().VisitSpace(imports.Before, p) - imports.Markers = v.visitMarkers(imports.Markers, p) - imports.Elements = visitRightPaddedList(v, imports.Elements, p) - cu = cu.WithImports(&imports) - } - cu = cu.WithStatements(visitRightPaddedList(v, cu.Statements, p)) - cu = cu.WithEOF(v.self().VisitSpace(cu.EOF, p)) - return cu + prefix := v.self().VisitSpace(cu.Prefix, p) + markers := v.visitMarkers(cu.Markers, p) + pkg := cu.PackageDecl + if pkg != nil { + elem := visitAndCast[*java.Identifier](v, pkg.Element, p) + after := v.self().VisitSpace(pkg.After, p) + if elem != pkg.Element || !java.SpaceEqual(after, pkg.After) { + c := *pkg + c.Element = elem + c.After = after + pkg = &c + } + } + imports := cu.Imports + if imports != nil { + before := v.self().VisitSpace(imports.Before, p) + impMarkers := v.visitMarkers(imports.Markers, p) + elems := visitRightPaddedList(v, imports.Elements, p) + if !java.SpaceEqual(before, imports.Before) || !java.MarkersEqual(impMarkers, imports.Markers) || !java.SameSlice(elems, imports.Elements) { + c := *imports + c.Before = before + c.Markers = impMarkers + c.Elements = elems + imports = &c + } + } + statements := visitRightPaddedList(v, cu.Statements, p) + eof := v.self().VisitSpace(cu.EOF, p) + if java.SpaceEqual(prefix, cu.Prefix) && java.MarkersEqual(markers, cu.Markers) && + pkg == cu.PackageDecl && imports == cu.Imports && + java.SameSlice(statements, cu.Statements) && java.SpaceEqual(eof, cu.EOF) { + return cu + } + c := *cu + c.Prefix = prefix + c.Markers = markers + c.PackageDecl = pkg + c.Imports = imports + c.Statements = statements + c.EOF = eof + return &c } func (v *GoVisitor) VisitGoMod(gm *golang.GoMod, p any) java.Tree { @@ -404,25 +427,28 @@ func (v *GoVisitor) VisitGoMod(gm *golang.GoMod, p any) java.Tree { func (v *GoVisitor) VisitGoModDirective(d *golang.GoModDirective, p any) java.Tree { d = d.WithPrefix(v.self().VisitSpace(d.Prefix, p)) d = d.WithMarkers(v.visitMarkers(d.Markers, p)) - values := make([]*golang.GoModValue, 0, len(d.Values)) - for _, val := range d.Values { - visited := v.self().Visit(val, p) - if visited == nil { - continue - } - values = append(values, visited.(*golang.GoModValue)) - } - d = d.WithValues(values) + d = d.WithValues(visitGoModValueList(v, d.Values, p)) return d } func (v *GoVisitor) VisitGoModBlock(b *golang.GoModBlock, p any) java.Tree { - b = b.WithPrefix(v.self().VisitSpace(b.Prefix, p)) - b = b.WithMarkers(v.visitMarkers(b.Markers, p)) - b.BeforeLParen = v.self().VisitSpace(b.BeforeLParen, p) - b = b.WithEntries(visitGoModStatementList(v, b.Entries, p)) - b.BeforeRParen = v.self().VisitSpace(b.BeforeRParen, p) - return b + prefix := v.self().VisitSpace(b.Prefix, p) + markers := v.visitMarkers(b.Markers, p) + beforeLParen := v.self().VisitSpace(b.BeforeLParen, p) + entries := visitGoModStatementList(v, b.Entries, p) + beforeRParen := v.self().VisitSpace(b.BeforeRParen, p) + if java.SpaceEqual(prefix, b.Prefix) && java.MarkersEqual(markers, b.Markers) && + java.SpaceEqual(beforeLParen, b.BeforeLParen) && java.SameSlice(entries, b.Entries) && + java.SpaceEqual(beforeRParen, b.BeforeRParen) { + return b + } + c := *b + c.Prefix = prefix + c.Markers = markers + c.BeforeLParen = beforeLParen + c.Entries = entries + c.BeforeRParen = beforeRParen + return &c } func (v *GoVisitor) VisitGoModValue(val *golang.GoModValue, p any) java.Tree { @@ -460,34 +486,63 @@ func (v *GoVisitor) VisitBlock(block *java.Block, p any) java.J { } func (v *GoVisitor) VisitReturn(ret *java.Return, p any) java.J { - ret = ret.WithPrefix(v.self().VisitSpace(ret.Prefix, p)) - ret = ret.WithMarkers(v.visitMarkers(ret.Markers, p)) - if ret.Expression != nil { - ret.Expression = visitAndCast[java.Expression](v, ret.Expression, p) + prefix := v.self().VisitSpace(ret.Prefix, p) + markers := v.visitMarkers(ret.Markers, p) + expr := ret.Expression + if expr != nil { + expr = visitAndCast[java.Expression](v, expr, p) } - return ret + if java.SpaceEqual(prefix, ret.Prefix) && java.MarkersEqual(markers, ret.Markers) && expr == ret.Expression { + return ret + } + c := *ret + c.Prefix = prefix + c.Markers = markers + c.Expression = expr + return &c } func (v *GoVisitor) VisitGoReturn(ret *golang.Return, p any) java.J { + prefix := v.self().VisitSpace(ret.Prefix, p) + markers := v.visitMarkers(ret.Markers, p) + exprs := visitRightPaddedExpressionList(v, ret.Expressions, p) + if java.SpaceEqual(prefix, ret.Prefix) && java.MarkersEqual(markers, ret.Markers) && java.SameSlice(exprs, ret.Expressions) { + return ret + } c := *ret - c.Prefix = v.self().VisitSpace(c.Prefix, p) - c.Markers = v.visitMarkers(c.Markers, p) - c.Expressions = visitRightPaddedExpressionList(v, c.Expressions, p) + c.Prefix = prefix + c.Markers = markers + c.Expressions = exprs return &c } func (v *GoVisitor) VisitIf(ifStmt *java.If, p any) java.J { - ifStmt = ifStmt.WithPrefix(v.self().VisitSpace(ifStmt.Prefix, p)) - ifStmt = ifStmt.WithMarkers(v.visitMarkers(ifStmt.Markers, p)) - ifStmt = ifStmt.WithCondition(visitAndCast[*java.ControlParentheses](v, ifStmt.Condition, p)) - ifStmt = ifStmt.WithThen(visitAndCast[*java.Block](v, ifStmt.Then, p)) - if ifStmt.ElsePart != nil { - ep := *ifStmt.ElsePart - ep.Element = v.self().Visit(ep.Element, p).(java.J) - ep.After = v.self().VisitSpace(ep.After, p) - ifStmt.ElsePart = &ep + prefix := v.self().VisitSpace(ifStmt.Prefix, p) + markers := v.visitMarkers(ifStmt.Markers, p) + condition := visitAndCast[*java.ControlParentheses](v, ifStmt.Condition, p) + then := visitAndCast[*java.Block](v, ifStmt.Then, p) + elsePart := ifStmt.ElsePart + if elsePart != nil { + elem := v.self().Visit(elsePart.Element, p).(java.J) + after := v.self().VisitSpace(elsePart.After, p) + if elem != elsePart.Element || !java.SpaceEqual(after, elsePart.After) { + c := *elsePart + c.Element = elem + c.After = after + elsePart = &c + } + } + if java.SpaceEqual(prefix, ifStmt.Prefix) && java.MarkersEqual(markers, ifStmt.Markers) && + condition == ifStmt.Condition && then == ifStmt.Then && elsePart == ifStmt.ElsePart { + return ifStmt } - return ifStmt + c := *ifStmt + c.Prefix = prefix + c.Markers = markers + c.Condition = condition + c.Then = then + c.ElsePart = elsePart + return &c } // VisitElse handles the synthetic *java.Else wrapper that JavaSender produces @@ -495,196 +550,333 @@ func (v *GoVisitor) VisitIf(ifStmt *java.If, p any) java.J { // Go AST — language-level recipes go through VisitIf instead — so the default // implementation simply visits the body so traversal terminates correctly. func (v *GoVisitor) VisitElse(el *java.Else, p any) java.J { - el = el.WithPrefix(v.self().VisitSpace(el.Prefix, p)) - el = el.WithMarkers(v.visitMarkers(el.Markers, p)) - body := el.Body - body.Element = v.self().Visit(body.Element, p).(java.Statement) - body.After = v.self().VisitSpace(body.After, p) - el.Body = body - return el + prefix := v.self().VisitSpace(el.Prefix, p) + markers := v.visitMarkers(el.Markers, p) + bodyElem := v.self().Visit(el.Body.Element, p).(java.Statement) + bodyAfter := v.self().VisitSpace(el.Body.After, p) + if java.SpaceEqual(prefix, el.Prefix) && java.MarkersEqual(markers, el.Markers) && + bodyElem == el.Body.Element && java.SpaceEqual(bodyAfter, el.Body.After) { + return el + } + c := *el + c.Prefix = prefix + c.Markers = markers + c.Body.Element = bodyElem + c.Body.After = bodyAfter + return &c } func (v *GoVisitor) VisitAssignment(assign *java.Assignment, p any) java.J { - assign = assign.WithPrefix(v.self().VisitSpace(assign.Prefix, p)) - assign = assign.WithMarkers(v.visitMarkers(assign.Markers, p)) - assign = assign.WithVariable(visitExpression(v, assign.Variable, p)) - assign.Value.Before = v.self().VisitSpace(assign.Value.Before, p) - assign.Value.Element = visitExpression(v, assign.Value.Element, p) - return assign + prefix := v.self().VisitSpace(assign.Prefix, p) + markers := v.visitMarkers(assign.Markers, p) + variable := visitExpression(v, assign.Variable, p) + valueBefore := v.self().VisitSpace(assign.Value.Before, p) + valueElem := visitExpression(v, assign.Value.Element, p) + if java.SpaceEqual(prefix, assign.Prefix) && java.MarkersEqual(markers, assign.Markers) && + variable == assign.Variable && + java.SpaceEqual(valueBefore, assign.Value.Before) && valueElem == assign.Value.Element { + return assign + } + c := *assign + c.Prefix = prefix + c.Markers = markers + c.Variable = variable + c.Value.Before = valueBefore + c.Value.Element = valueElem + return &c } func (v *GoVisitor) VisitMethodDeclaration(md *java.MethodDeclaration, p any) java.J { - md = md.WithPrefix(v.self().VisitSpace(md.Prefix, p)) - md = md.WithMarkers(v.visitMarkers(md.Markers, p)) - if len(md.LeadingAnnotations) > 0 { - anns := make([]*java.Annotation, 0, len(md.LeadingAnnotations)) - for _, a := range md.LeadingAnnotations { - visited := v.self().Visit(a, p) - if visited == nil { - continue - } - anns = append(anns, visited.(*java.Annotation)) - } - md = md.WithLeadingAnnotations(anns) + prefix := v.self().VisitSpace(md.Prefix, p) + markers := v.visitMarkers(md.Markers, p) + anns := visitAnnotationList(v, md.LeadingAnnotations, p) + name := visitAndCast[*java.Identifier](v, md.Name, p) + tps := md.TypeParameters + if tps != nil { + tps = visitAndCast[*java.TypeParameters](v, tps, p) } - md = md.WithName(visitAndCast[*java.Identifier](v, md.Name, p)) - if md.TypeParameters != nil { - md = md.WithTypeParameters(visitAndCast[*java.TypeParameters](v, md.TypeParameters, p)) + paramsBefore := v.self().VisitSpace(md.Parameters.Before, p) + paramsElems := visitRightPaddedList(v, md.Parameters.Elements, p) + returnType := md.ReturnType + if returnType != nil { + returnType = visitExpression(v, returnType, p) } - md.Parameters.Before = v.self().VisitSpace(md.Parameters.Before, p) - md.Parameters.Elements = visitRightPaddedList(v, md.Parameters.Elements, p) - if md.ReturnType != nil { - md.ReturnType = visitExpression(v, md.ReturnType, p) + body := md.Body + if body != nil { + body = visitAndCast[*java.Block](v, body, p) } - if md.Body != nil { - md = md.WithBody(visitAndCast[*java.Block](v, md.Body, p)) + if java.SpaceEqual(prefix, md.Prefix) && java.MarkersEqual(markers, md.Markers) && + java.SameSlice(anns, md.LeadingAnnotations) && name == md.Name && tps == md.TypeParameters && + java.SpaceEqual(paramsBefore, md.Parameters.Before) && java.SameSlice(paramsElems, md.Parameters.Elements) && + returnType == md.ReturnType && body == md.Body { + return md } - return md + c := *md + c.Prefix = prefix + c.Markers = markers + c.LeadingAnnotations = anns + c.Name = name + c.TypeParameters = tps + c.Parameters.Before = paramsBefore + c.Parameters.Elements = paramsElems + c.ReturnType = returnType + c.Body = body + return &c } func (v *GoVisitor) VisitGoMethodDeclaration(md *golang.MethodDeclaration, p any) java.J { + prefix := v.self().VisitSpace(md.Prefix, p) + markers := v.visitMarkers(md.Markers, p) + recvBefore := v.self().VisitSpace(md.Receiver.Before, p) + recvElems := visitRightPaddedList(v, md.Receiver.Elements, p) + decl := visitAndCast[*java.MethodDeclaration](v, md.Declaration, p) + if java.SpaceEqual(prefix, md.Prefix) && java.MarkersEqual(markers, md.Markers) && + java.SpaceEqual(recvBefore, md.Receiver.Before) && java.SameSlice(recvElems, md.Receiver.Elements) && + decl == md.Declaration { + return md + } c := *md - c.Prefix = v.self().VisitSpace(c.Prefix, p) - c.Markers = v.visitMarkers(c.Markers, p) - c.Receiver.Before = v.self().VisitSpace(c.Receiver.Before, p) - c.Receiver.Elements = visitRightPaddedList(v, c.Receiver.Elements, p) - c.Declaration = visitAndCast[*java.MethodDeclaration](v, c.Declaration, p) + c.Prefix = prefix + c.Markers = markers + c.Receiver.Before = recvBefore + c.Receiver.Elements = recvElems + c.Declaration = decl return &c } func (v *GoVisitor) VisitStatementWithInit(s *golang.StatementWithInit, p any) java.J { + prefix := v.self().VisitSpace(s.Prefix, p) + markers := v.visitMarkers(s.Markers, p) + initElem := v.self().Visit(s.Init.Element, p).(java.Statement) + initAfter := v.self().VisitSpace(s.Init.After, p) + stmt := v.self().Visit(s.Statement, p).(java.Statement) + if java.SpaceEqual(prefix, s.Prefix) && java.MarkersEqual(markers, s.Markers) && + initElem == s.Init.Element && java.SpaceEqual(initAfter, s.Init.After) && stmt == s.Statement { + return s + } c := *s - c.Prefix = v.self().VisitSpace(c.Prefix, p) - c.Markers = v.visitMarkers(c.Markers, p) - c.Init.Element = v.self().Visit(c.Init.Element, p).(java.Statement) - c.Init.After = v.self().VisitSpace(c.Init.After, p) - c.Statement = v.self().Visit(c.Statement, p).(java.Statement) + c.Prefix = prefix + c.Markers = markers + c.Init.Element = initElem + c.Init.After = initAfter + c.Statement = stmt return &c } func (v *GoVisitor) VisitTypeParameters(tps *java.TypeParameters, p any) java.J { - tps = tps.WithPrefix(v.self().VisitSpace(tps.Prefix, p)) - tps = tps.WithMarkers(v.visitMarkers(tps.Markers, p)) - tps.TypeParameters = visitRightPaddedList(v, tps.TypeParameters, p) - return tps + prefix := v.self().VisitSpace(tps.Prefix, p) + markers := v.visitMarkers(tps.Markers, p) + params := visitRightPaddedList(v, tps.TypeParameters, p) + if java.SpaceEqual(prefix, tps.Prefix) && java.MarkersEqual(markers, tps.Markers) && + java.SameSlice(params, tps.TypeParameters) { + return tps + } + c := *tps + c.Prefix = prefix + c.Markers = markers + c.TypeParameters = params + return &c } func (v *GoVisitor) VisitTypeParameter(tp *java.TypeParameter, p any) java.J { - tp = tp.WithPrefix(v.self().VisitSpace(tp.Prefix, p)) - tp = tp.WithMarkers(v.visitMarkers(tp.Markers, p)) - if tp.Name != nil { - tp.Name = visitExpression(v, tp.Name, p) + prefix := v.self().VisitSpace(tp.Prefix, p) + markers := v.visitMarkers(tp.Markers, p) + name := tp.Name + if name != nil { + name = visitExpression(v, name, p) } - if tp.Bounds != nil { - tp.Bounds.Before = v.self().VisitSpace(tp.Bounds.Before, p) - tp.Bounds.Elements = visitRightPaddedList(v, tp.Bounds.Elements, p) + bounds := tp.Bounds + if bounds != nil { + before := v.self().VisitSpace(bounds.Before, p) + elems := visitRightPaddedList(v, bounds.Elements, p) + if !java.SpaceEqual(before, bounds.Before) || !java.SameSlice(elems, bounds.Elements) { + c := *bounds + c.Before = before + c.Elements = elems + bounds = &c + } } - return tp + if java.SpaceEqual(prefix, tp.Prefix) && java.MarkersEqual(markers, tp.Markers) && + name == tp.Name && bounds == tp.Bounds { + return tp + } + c := *tp + c.Prefix = prefix + c.Markers = markers + c.Name = name + c.Bounds = bounds + return &c } func (v *GoVisitor) VisitFieldAccess(fa *java.FieldAccess, p any) java.J { - fa = fa.WithPrefix(v.self().VisitSpace(fa.Prefix, p)) - fa = fa.WithMarkers(v.visitMarkers(fa.Markers, p)) - fa = fa.WithTarget(visitExpression(v, fa.Target, p)) + prefix := v.self().VisitSpace(fa.Prefix, p) + markers := v.visitMarkers(fa.Markers, p) + target := visitExpression(v, fa.Target, p) // Visit the selector identifier so recipes that traverse identifiers // see the right-hand side of `target.Name` (e.g. the `Box` in // `a.Box[int]{...}`). Mirrors JavaIsoVisitor.visitFieldAccess. - name := fa.Name - name.Before = v.self().VisitSpace(name.Before, p) - name.Element = visitAndCast[*java.Identifier](v, name.Element, p) - fa.Name = name - return fa + nameBefore := v.self().VisitSpace(fa.Name.Before, p) + nameElem := visitAndCast[*java.Identifier](v, fa.Name.Element, p) + if java.SpaceEqual(prefix, fa.Prefix) && java.MarkersEqual(markers, fa.Markers) && + target == fa.Target && + java.SpaceEqual(nameBefore, fa.Name.Before) && nameElem == fa.Name.Element { + return fa + } + c := *fa + c.Prefix = prefix + c.Markers = markers + c.Target = target + c.Name.Before = nameBefore + c.Name.Element = nameElem + return &c } func (v *GoVisitor) VisitMethodInvocation(mi *java.MethodInvocation, p any) java.J { - mi = mi.WithPrefix(v.self().VisitSpace(mi.Prefix, p)) - mi = mi.WithMarkers(v.visitMarkers(mi.Markers, p)) - if mi.Select != nil { - sel := *mi.Select - sel.Element = visitExpression(v, sel.Element, p) - sel.After = v.self().VisitSpace(sel.After, p) - mi.Select = &sel - } - mi = mi.WithName(visitAndCast[*java.Identifier](v, mi.Name, p)) - if mi.TypeParameters != nil { - tp := *mi.TypeParameters - tp.Before = v.self().VisitSpace(tp.Before, p) - tp.Elements = visitRightPaddedList(v, tp.Elements, p) - mi.TypeParameters = &tp - } - mi.Arguments.Before = v.self().VisitSpace(mi.Arguments.Before, p) - mi.Arguments.Elements = visitRightPaddedList(v, mi.Arguments.Elements, p) - return mi + prefix := v.self().VisitSpace(mi.Prefix, p) + markers := v.visitMarkers(mi.Markers, p) + sel := mi.Select + if sel != nil { + e := visitExpression(v, sel.Element, p) + a := v.self().VisitSpace(sel.After, p) + if e != sel.Element || !java.SpaceEqual(a, sel.After) { + c := *sel + c.Element = e + c.After = a + sel = &c + } + } + name := visitAndCast[*java.Identifier](v, mi.Name, p) + tps := mi.TypeParameters + if tps != nil { + before := v.self().VisitSpace(tps.Before, p) + elems := visitRightPaddedList(v, tps.Elements, p) + if !java.SpaceEqual(before, tps.Before) || !java.SameSlice(elems, tps.Elements) { + c := *tps + c.Before = before + c.Elements = elems + tps = &c + } + } + argsBefore := v.self().VisitSpace(mi.Arguments.Before, p) + argsElems := visitRightPaddedList(v, mi.Arguments.Elements, p) + if java.SpaceEqual(prefix, mi.Prefix) && java.MarkersEqual(markers, mi.Markers) && + sel == mi.Select && name == mi.Name && tps == mi.TypeParameters && + java.SpaceEqual(argsBefore, mi.Arguments.Before) && java.SameSlice(argsElems, mi.Arguments.Elements) { + return mi + } + c := *mi + c.Prefix = prefix + c.Markers = markers + c.Select = sel + c.Name = name + c.TypeParameters = tps + c.Arguments.Before = argsBefore + c.Arguments.Elements = argsElems + return &c } func (v *GoVisitor) VisitVariableDeclarations(vd *java.VariableDeclarations, p any) java.J { - vd = vd.WithPrefix(v.self().VisitSpace(vd.Prefix, p)) - vd = vd.WithMarkers(v.visitMarkers(vd.Markers, p)) - if len(vd.LeadingAnnotations) > 0 { - anns := make([]*java.Annotation, 0, len(vd.LeadingAnnotations)) - for _, a := range vd.LeadingAnnotations { - visited := v.self().Visit(a, p) - if visited == nil { - continue - } - anns = append(anns, visited.(*java.Annotation)) - } - vd = vd.WithLeadingAnnotations(anns) + prefix := v.self().VisitSpace(vd.Prefix, p) + markers := v.visitMarkers(vd.Markers, p) + anns := visitAnnotationList(v, vd.LeadingAnnotations, p) + typeExpr := vd.TypeExpr + if typeExpr != nil { + typeExpr = visitExpression(v, typeExpr, p) } - if vd.TypeExpr != nil { - vd.TypeExpr = visitExpression(v, vd.TypeExpr, p) + variables := visitRightPaddedList(v, vd.Variables, p) + if java.SpaceEqual(prefix, vd.Prefix) && java.MarkersEqual(markers, vd.Markers) && + java.SameSlice(anns, vd.LeadingAnnotations) && typeExpr == vd.TypeExpr && + java.SameSlice(variables, vd.Variables) { + return vd } - vd.Variables = visitRightPaddedList(v, vd.Variables, p) - return vd + c := *vd + c.Prefix = prefix + c.Markers = markers + c.LeadingAnnotations = anns + c.TypeExpr = typeExpr + c.Variables = variables + return &c } func (v *GoVisitor) VisitDeclarationBlock(db *golang.DeclarationBlock, p any) java.J { - db = db.WithPrefix(v.self().VisitSpace(db.Prefix, p)) - db = db.WithMarkers(v.visitMarkers(db.Markers, p)) - if len(db.LeadingAnnotations) > 0 { - anns := make([]*java.Annotation, 0, len(db.LeadingAnnotations)) - for _, a := range db.LeadingAnnotations { - visited := v.self().Visit(a, p) - if visited == nil { - continue - } - anns = append(anns, visited.(*java.Annotation)) + prefix := v.self().VisitSpace(db.Prefix, p) + markers := v.visitMarkers(db.Markers, p) + anns := visitAnnotationList(v, db.LeadingAnnotations, p) + specs := db.Specs + if specs != nil { + before := v.self().VisitSpace(specs.Before, p) + elems := visitRightPaddedList(v, specs.Elements, p) + if !java.SpaceEqual(before, specs.Before) || !java.SameSlice(elems, specs.Elements) { + c := *specs + c.Before = before + c.Elements = elems + specs = &c } - db = db.WithLeadingAnnotations(anns) } - if db.Specs != nil { - specs := *db.Specs - specs.Before = v.self().VisitSpace(specs.Before, p) - specs.Elements = visitRightPaddedList(v, specs.Elements, p) - db.Specs = &specs + if java.SpaceEqual(prefix, db.Prefix) && java.MarkersEqual(markers, db.Markers) && + java.SameSlice(anns, db.LeadingAnnotations) && specs == db.Specs { + return db } - return db + c := *db + c.Prefix = prefix + c.Markers = markers + c.LeadingAnnotations = anns + c.Specs = specs + return &c } func (v *GoVisitor) VisitVariableDeclarator(vd *java.VariableDeclarator, p any) java.J { - vd = vd.WithPrefix(v.self().VisitSpace(vd.Prefix, p)) - vd = vd.WithMarkers(v.visitMarkers(vd.Markers, p)) - vd = vd.WithName(visitAndCast[*java.Identifier](v, vd.Name, p)) - if vd.Initializer != nil { - init := *vd.Initializer - init.Before = v.self().VisitSpace(init.Before, p) - init.Element = visitExpression(v, init.Element, p) - vd.Initializer = &init + prefix := v.self().VisitSpace(vd.Prefix, p) + markers := v.visitMarkers(vd.Markers, p) + name := visitAndCast[*java.Identifier](v, vd.Name, p) + init := vd.Initializer + if init != nil { + before := v.self().VisitSpace(init.Before, p) + elem := visitExpression(v, init.Element, p) + if !java.SpaceEqual(before, init.Before) || elem != init.Element { + c := *init + c.Before = before + c.Element = elem + init = &c + } + } + if java.SpaceEqual(prefix, vd.Prefix) && java.MarkersEqual(markers, vd.Markers) && + name == vd.Name && init == vd.Initializer { + return vd } - return vd + c := *vd + c.Prefix = prefix + c.Markers = markers + c.Name = name + c.Initializer = init + return &c } func (v *GoVisitor) VisitImport(imp *java.Import, p any) java.J { - imp = imp.WithPrefix(v.self().VisitSpace(imp.Prefix, p)) - imp = imp.WithMarkers(v.visitMarkers(imp.Markers, p)) - if imp.Alias != nil { - alias := *imp.Alias - alias.Before = v.self().VisitSpace(alias.Before, p) - alias.Element = visitAndCast[*java.Identifier](v, alias.Element, p) - imp.Alias = &alias + prefix := v.self().VisitSpace(imp.Prefix, p) + markers := v.visitMarkers(imp.Markers, p) + alias := imp.Alias + if alias != nil { + before := v.self().VisitSpace(alias.Before, p) + elem := visitAndCast[*java.Identifier](v, alias.Element, p) + if !java.SpaceEqual(before, alias.Before) || elem != alias.Element { + c := *alias + c.Before = before + c.Element = elem + alias = &c + } + } + qualid := visitExpression(v, imp.Qualid, p) + if java.SpaceEqual(prefix, imp.Prefix) && java.MarkersEqual(markers, imp.Markers) && + alias == imp.Alias && qualid == imp.Qualid { + return imp } - imp.Qualid = visitExpression(v, imp.Qualid, p) - return imp + c := *imp + c.Prefix = prefix + c.Markers = markers + c.Alias = alias + c.Qualid = qualid + return &c } func (v *GoVisitor) VisitUnary(unary *java.Unary, p any) java.J { @@ -695,11 +887,20 @@ func (v *GoVisitor) VisitUnary(unary *java.Unary, p any) java.J { } func (v *GoVisitor) VisitAssignmentOperation(ao *java.AssignmentOperation, p any) java.J { - ao = ao.WithPrefix(v.self().VisitSpace(ao.Prefix, p)) - ao = ao.WithMarkers(v.visitMarkers(ao.Markers, p)) - ao = ao.WithVariable(visitExpression(v, ao.Variable, p)) - ao.Assignment = visitExpression(v, ao.Assignment, p) - return ao + prefix := v.self().VisitSpace(ao.Prefix, p) + markers := v.visitMarkers(ao.Markers, p) + variable := visitExpression(v, ao.Variable, p) + assignment := visitExpression(v, ao.Assignment, p) + if java.SpaceEqual(prefix, ao.Prefix) && java.MarkersEqual(markers, ao.Markers) && + variable == ao.Variable && assignment == ao.Assignment { + return ao + } + c := *ao + c.Prefix = prefix + c.Markers = markers + c.Variable = variable + c.Assignment = assignment + return &c } func (v *GoVisitor) VisitSwitch(sw *java.Switch, p any) java.J { @@ -709,45 +910,90 @@ func (v *GoVisitor) VisitSwitch(sw *java.Switch, p any) java.J { return sw } -func (v *GoVisitor) VisitCase(c *java.Case, p any) java.J { - c = c.WithPrefix(v.self().VisitSpace(c.Prefix, p)) - c = c.WithMarkers(v.visitMarkers(c.Markers, p)) - c.Expressions.Before = v.self().VisitSpace(c.Expressions.Before, p) - c.Expressions.Elements = visitRightPaddedExpressionList(v, c.Expressions.Elements, p) - c.Body = visitRightPaddedList(v, c.Body, p) - return c +func (v *GoVisitor) VisitCase(cse *java.Case, p any) java.J { + prefix := v.self().VisitSpace(cse.Prefix, p) + markers := v.visitMarkers(cse.Markers, p) + exprsBefore := v.self().VisitSpace(cse.Expressions.Before, p) + exprsElems := visitRightPaddedExpressionList(v, cse.Expressions.Elements, p) + body := visitRightPaddedList(v, cse.Body, p) + if java.SpaceEqual(prefix, cse.Prefix) && java.MarkersEqual(markers, cse.Markers) && + java.SpaceEqual(exprsBefore, cse.Expressions.Before) && java.SameSlice(exprsElems, cse.Expressions.Elements) && + java.SameSlice(body, cse.Body) { + return cse + } + c := *cse + c.Prefix = prefix + c.Markers = markers + c.Expressions.Before = exprsBefore + c.Expressions.Elements = exprsElems + c.Body = body + return &c } func (v *GoVisitor) VisitForLoop(forLoop *java.ForLoop, p any) java.J { - forLoop = forLoop.WithPrefix(v.self().VisitSpace(forLoop.Prefix, p)) - forLoop = forLoop.WithMarkers(v.visitMarkers(forLoop.Markers, p)) - forLoop.Control = *visitAndCast[*java.ForControl](v, &forLoop.Control, p) - forLoop = forLoop.WithBody(visitAndCast[*java.Block](v, forLoop.Body, p)) - return forLoop + prefix := v.self().VisitSpace(forLoop.Prefix, p) + markers := v.visitMarkers(forLoop.Markers, p) + ctrl := visitAndCast[*java.ForControl](v, &forLoop.Control, p) + body := visitAndCast[*java.Block](v, forLoop.Body, p) + if java.SpaceEqual(prefix, forLoop.Prefix) && java.MarkersEqual(markers, forLoop.Markers) && + ctrl == &forLoop.Control && body == forLoop.Body { + return forLoop + } + c := *forLoop + c.Prefix = prefix + c.Markers = markers + c.Control = *ctrl + c.Body = body + return &c } func (v *GoVisitor) VisitForControl(control *java.ForControl, p any) java.J { - control = control.WithPrefix(v.self().VisitSpace(control.Prefix, p)) - control = control.WithMarkers(v.visitMarkers(control.Markers, p)) - if control.Init != nil { - init := *control.Init - init.Element = visitAndCast[java.Statement](v, init.Element, p) - init.After = v.self().VisitSpace(init.After, p) - control.Init = &init + prefix := v.self().VisitSpace(control.Prefix, p) + markers := v.visitMarkers(control.Markers, p) + init := control.Init + if init != nil { + elem := visitAndCast[java.Statement](v, init.Element, p) + after := v.self().VisitSpace(init.After, p) + if elem != init.Element || !java.SpaceEqual(after, init.After) { + c := *init + c.Element = elem + c.After = after + init = &c + } } - if control.Condition != nil { - cond := *control.Condition - cond.Element = visitExpression(v, cond.Element, p) - cond.After = v.self().VisitSpace(cond.After, p) - control.Condition = &cond + cond := control.Condition + if cond != nil { + elem := visitExpression(v, cond.Element, p) + after := v.self().VisitSpace(cond.After, p) + if elem != cond.Element || !java.SpaceEqual(after, cond.After) { + c := *cond + c.Element = elem + c.After = after + cond = &c + } + } + update := control.Update + if update != nil { + elem := visitAndCast[java.Statement](v, update.Element, p) + after := v.self().VisitSpace(update.After, p) + if elem != update.Element || !java.SpaceEqual(after, update.After) { + c := *update + c.Element = elem + c.After = after + update = &c + } } - if control.Update != nil { - update := *control.Update - update.Element = visitAndCast[java.Statement](v, update.Element, p) - update.After = v.self().VisitSpace(update.After, p) - control.Update = &update + if java.SpaceEqual(prefix, control.Prefix) && java.MarkersEqual(markers, control.Markers) && + init == control.Init && cond == control.Condition && update == control.Update { + return control } - return control + c := *control + c.Prefix = prefix + c.Markers = markers + c.Init = init + c.Condition = cond + c.Update = update + return &c } func (v *GoVisitor) VisitForEachLoop(forEach *java.ForEachLoop, p any) java.J { @@ -758,112 +1004,215 @@ func (v *GoVisitor) VisitForEachLoop(forEach *java.ForEachLoop, p any) java.J { } func (v *GoVisitor) VisitForEachControl(control *java.ForEachControl, p any) java.J { - control = control.WithPrefix(v.self().VisitSpace(control.Prefix, p)) - control = control.WithMarkers(v.visitMarkers(control.Markers, p)) - control.Variable.Element = visitAndCast[java.Statement](v, control.Variable.Element, p) - control.Variable.After = v.self().VisitSpace(control.Variable.After, p) - control.Iterable.Element = visitExpression(v, control.Iterable.Element, p) - control.Iterable.After = v.self().VisitSpace(control.Iterable.After, p) - return control + prefix := v.self().VisitSpace(control.Prefix, p) + markers := v.visitMarkers(control.Markers, p) + varElem := visitAndCast[java.Statement](v, control.Variable.Element, p) + varAfter := v.self().VisitSpace(control.Variable.After, p) + iterElem := visitExpression(v, control.Iterable.Element, p) + iterAfter := v.self().VisitSpace(control.Iterable.After, p) + if java.SpaceEqual(prefix, control.Prefix) && java.MarkersEqual(markers, control.Markers) && + varElem == control.Variable.Element && java.SpaceEqual(varAfter, control.Variable.After) && + iterElem == control.Iterable.Element && java.SpaceEqual(iterAfter, control.Iterable.After) { + return control + } + c := *control + c.Prefix = prefix + c.Markers = markers + c.Variable.Element = varElem + c.Variable.After = varAfter + c.Iterable.Element = iterElem + c.Iterable.After = iterAfter + return &c } func (v *GoVisitor) VisitBreak(b *java.Break, p any) java.J { - b = b.WithPrefix(v.self().VisitSpace(b.Prefix, p)) - b = b.WithMarkers(v.visitMarkers(b.Markers, p)) - if b.Label != nil { - b.Label = visitAndCast[*java.Identifier](v, b.Label, p) + prefix := v.self().VisitSpace(b.Prefix, p) + markers := v.visitMarkers(b.Markers, p) + label := b.Label + if label != nil { + label = visitAndCast[*java.Identifier](v, label, p) } - return b + if java.SpaceEqual(prefix, b.Prefix) && java.MarkersEqual(markers, b.Markers) && label == b.Label { + return b + } + c := *b + c.Prefix = prefix + c.Markers = markers + c.Label = label + return &c } -func (v *GoVisitor) VisitContinue(c *java.Continue, p any) java.J { - c = c.WithPrefix(v.self().VisitSpace(c.Prefix, p)) - c = c.WithMarkers(v.visitMarkers(c.Markers, p)) - if c.Label != nil { - c.Label = visitAndCast[*java.Identifier](v, c.Label, p) +func (v *GoVisitor) VisitContinue(cont *java.Continue, p any) java.J { + prefix := v.self().VisitSpace(cont.Prefix, p) + markers := v.visitMarkers(cont.Markers, p) + label := cont.Label + if label != nil { + label = visitAndCast[*java.Identifier](v, label, p) + } + if java.SpaceEqual(prefix, cont.Prefix) && java.MarkersEqual(markers, cont.Markers) && label == cont.Label { + return cont } - return c + c := *cont + c.Prefix = prefix + c.Markers = markers + c.Label = label + return &c } func (v *GoVisitor) VisitLabel(l *java.Label, p any) java.J { - l = l.WithPrefix(v.self().VisitSpace(l.Prefix, p)) - l = l.WithMarkers(v.visitMarkers(l.Markers, p)) - l.Name.Element = visitAndCast[*java.Identifier](v, l.Name.Element, p) - l.Name.After = v.self().VisitSpace(l.Name.After, p) - l.Statement = visitAndCast[java.Statement](v, l.Statement, p) - return l + prefix := v.self().VisitSpace(l.Prefix, p) + markers := v.visitMarkers(l.Markers, p) + nameElem := visitAndCast[*java.Identifier](v, l.Name.Element, p) + nameAfter := v.self().VisitSpace(l.Name.After, p) + stmt := visitAndCast[java.Statement](v, l.Statement, p) + if java.SpaceEqual(prefix, l.Prefix) && java.MarkersEqual(markers, l.Markers) && + nameElem == l.Name.Element && java.SpaceEqual(nameAfter, l.Name.After) && stmt == l.Statement { + return l + } + c := *l + c.Prefix = prefix + c.Markers = markers + c.Name.Element = nameElem + c.Name.After = nameAfter + c.Statement = stmt + return &c } func (v *GoVisitor) VisitGoStmt(g *golang.GoStmt, p any) java.J { - g = g.WithPrefix(v.self().VisitSpace(g.Prefix, p)) - g = g.WithMarkers(v.visitMarkers(g.Markers, p)) - g.Expr = visitExpression(v, g.Expr, p) - return g + prefix := v.self().VisitSpace(g.Prefix, p) + markers := v.visitMarkers(g.Markers, p) + expr := visitExpression(v, g.Expr, p) + if java.SpaceEqual(prefix, g.Prefix) && java.MarkersEqual(markers, g.Markers) && expr == g.Expr { + return g + } + c := *g + c.Prefix = prefix + c.Markers = markers + c.Expr = expr + return &c } func (v *GoVisitor) VisitDefer(d *golang.Defer, p any) java.J { - d = d.WithPrefix(v.self().VisitSpace(d.Prefix, p)) - d = d.WithMarkers(v.visitMarkers(d.Markers, p)) - d.Expr = visitExpression(v, d.Expr, p) - return d + prefix := v.self().VisitSpace(d.Prefix, p) + markers := v.visitMarkers(d.Markers, p) + expr := visitExpression(v, d.Expr, p) + if java.SpaceEqual(prefix, d.Prefix) && java.MarkersEqual(markers, d.Markers) && expr == d.Expr { + return d + } + c := *d + c.Prefix = prefix + c.Markers = markers + c.Expr = expr + return &c } func (v *GoVisitor) VisitSend(s *golang.Send, p any) java.J { - s = s.WithPrefix(v.self().VisitSpace(s.Prefix, p)) - s = s.WithMarkers(v.visitMarkers(s.Markers, p)) - s.Channel = visitExpression(v, s.Channel, p) - s.Arrow.Before = v.self().VisitSpace(s.Arrow.Before, p) - s.Arrow.Element = visitExpression(v, s.Arrow.Element, p) - return s + prefix := v.self().VisitSpace(s.Prefix, p) + markers := v.visitMarkers(s.Markers, p) + channel := visitExpression(v, s.Channel, p) + arrowBefore := v.self().VisitSpace(s.Arrow.Before, p) + arrowElem := visitExpression(v, s.Arrow.Element, p) + if java.SpaceEqual(prefix, s.Prefix) && java.MarkersEqual(markers, s.Markers) && + channel == s.Channel && + java.SpaceEqual(arrowBefore, s.Arrow.Before) && arrowElem == s.Arrow.Element { + return s + } + c := *s + c.Prefix = prefix + c.Markers = markers + c.Channel = channel + c.Arrow.Before = arrowBefore + c.Arrow.Element = arrowElem + return &c } func (v *GoVisitor) VisitGoto(g *golang.Goto, p any) java.J { - g = g.WithPrefix(v.self().VisitSpace(g.Prefix, p)) - g = g.WithMarkers(v.visitMarkers(g.Markers, p)) - if g.Label != nil { - g.Label = visitAndCast[*java.Identifier](v, g.Label, p) + prefix := v.self().VisitSpace(g.Prefix, p) + markers := v.visitMarkers(g.Markers, p) + label := g.Label + if label != nil { + label = visitAndCast[*java.Identifier](v, label, p) } - return g + if java.SpaceEqual(prefix, g.Prefix) && java.MarkersEqual(markers, g.Markers) && label == g.Label { + return g + } + c := *g + c.Prefix = prefix + c.Markers = markers + c.Label = label + return &c } func (v *GoVisitor) VisitGoUnary(u *golang.Unary, p any) java.J { - u = u.WithPrefix(v.self().VisitSpace(u.Prefix, p)) - u = u.WithMarkers(v.visitMarkers(u.Markers, p)) - op := u.Operator - op.Before = v.self().VisitSpace(op.Before, p) - u.Operator = op - u.Expression = visitAndCast[java.Expression](v, u.Expression, p) - return u + prefix := v.self().VisitSpace(u.Prefix, p) + markers := v.visitMarkers(u.Markers, p) + opBefore := v.self().VisitSpace(u.Operator.Before, p) + expr := visitAndCast[java.Expression](v, u.Expression, p) + if java.SpaceEqual(prefix, u.Prefix) && java.MarkersEqual(markers, u.Markers) && + java.SpaceEqual(opBefore, u.Operator.Before) && expr == u.Expression { + return u + } + c := *u + c.Prefix = prefix + c.Markers = markers + c.Operator.Before = opBefore + c.Expression = expr + return &c } func (v *GoVisitor) VisitGoBinary(b *golang.Binary, p any) java.J { - b = b.WithPrefix(v.self().VisitSpace(b.Prefix, p)) - b = b.WithMarkers(v.visitMarkers(b.Markers, p)) - b.Left = visitAndCast[java.Expression](v, b.Left, p) - op := b.Operator - op.Before = v.self().VisitSpace(op.Before, p) - b.Operator = op - b.Right = visitAndCast[java.Expression](v, b.Right, p) - return b + prefix := v.self().VisitSpace(b.Prefix, p) + markers := v.visitMarkers(b.Markers, p) + left := visitAndCast[java.Expression](v, b.Left, p) + opBefore := v.self().VisitSpace(b.Operator.Before, p) + right := visitAndCast[java.Expression](v, b.Right, p) + if java.SpaceEqual(prefix, b.Prefix) && java.MarkersEqual(markers, b.Markers) && + left == b.Left && java.SpaceEqual(opBefore, b.Operator.Before) && right == b.Right { + return b + } + c := *b + c.Prefix = prefix + c.Markers = markers + c.Left = left + c.Operator.Before = opBefore + c.Right = right + return &c } func (v *GoVisitor) VisitGoAssignmentOperation(a *golang.AssignmentOperation, p any) java.J { - a = a.WithPrefix(v.self().VisitSpace(a.Prefix, p)) - a = a.WithMarkers(v.visitMarkers(a.Markers, p)) - a.Variable = visitAndCast[java.Expression](v, a.Variable, p) - op := a.Operator - op.Before = v.self().VisitSpace(op.Before, p) - a.Operator = op - a.Assignment = visitAndCast[java.Expression](v, a.Assignment, p) - return a + prefix := v.self().VisitSpace(a.Prefix, p) + markers := v.visitMarkers(a.Markers, p) + variable := visitAndCast[java.Expression](v, a.Variable, p) + opBefore := v.self().VisitSpace(a.Operator.Before, p) + assignment := visitAndCast[java.Expression](v, a.Assignment, p) + if java.SpaceEqual(prefix, a.Prefix) && java.MarkersEqual(markers, a.Markers) && + variable == a.Variable && java.SpaceEqual(opBefore, a.Operator.Before) && assignment == a.Assignment { + return a + } + c := *a + c.Prefix = prefix + c.Markers = markers + c.Variable = variable + c.Operator.Before = opBefore + c.Assignment = assignment + return &c } func (v *GoVisitor) VisitGoVariadic(vr *golang.Variadic, p any) java.J { - vr = vr.WithPrefix(v.self().VisitSpace(vr.Prefix, p)) - vr = vr.WithMarkers(v.visitMarkers(vr.Markers, p)) - vr.Element = visitAndCast[java.Expression](v, vr.Element, p) - vr.Dots = v.self().VisitSpace(vr.Dots, p) - return vr + prefix := v.self().VisitSpace(vr.Prefix, p) + markers := v.visitMarkers(vr.Markers, p) + elem := visitAndCast[java.Expression](v, vr.Element, p) + dots := v.self().VisitSpace(vr.Dots, p) + if java.SpaceEqual(prefix, vr.Prefix) && java.MarkersEqual(markers, vr.Markers) && + elem == vr.Element && java.SpaceEqual(dots, vr.Dots) { + return vr + } + c := *vr + c.Prefix = prefix + c.Markers = markers + c.Element = elem + c.Dots = dots + return &c } func (v *GoVisitor) VisitFallthrough(f *golang.Fallthrough, p any) java.J { @@ -878,283 +1227,562 @@ func (v *GoVisitor) VisitEmpty(empty *java.Empty, p any) java.J { } func (v *GoVisitor) VisitAnnotation(ann *java.Annotation, p any) java.J { - ann = ann.WithPrefix(v.self().VisitSpace(ann.Prefix, p)) - ann = ann.WithMarkers(v.visitMarkers(ann.Markers, p)) - if ann.AnnotationType != nil { - ann = ann.WithAnnotationType(visitExpression(v, ann.AnnotationType, p)) + prefix := v.self().VisitSpace(ann.Prefix, p) + markers := v.visitMarkers(ann.Markers, p) + annType := ann.AnnotationType + if annType != nil { + annType = visitExpression(v, annType, p) + } + args := ann.Arguments + if args != nil { + before := v.self().VisitSpace(args.Before, p) + argMarkers := v.visitMarkers(args.Markers, p) + elems := visitRightPaddedExpressionList(v, args.Elements, p) + if !java.SpaceEqual(before, args.Before) || !java.MarkersEqual(argMarkers, args.Markers) || !java.SameSlice(elems, args.Elements) { + c := *args + c.Before = before + c.Markers = argMarkers + c.Elements = elems + args = &c + } } - if ann.Arguments != nil { - args := *ann.Arguments - args.Before = v.self().VisitSpace(args.Before, p) - args.Markers = v.visitMarkers(args.Markers, p) - args.Elements = visitRightPaddedExpressionList(v, args.Elements, p) - ann = ann.WithArguments(&args) + if java.SpaceEqual(prefix, ann.Prefix) && java.MarkersEqual(markers, ann.Markers) && + annType == ann.AnnotationType && args == ann.Arguments { + return ann } - return ann + c := *ann + c.Prefix = prefix + c.Markers = markers + c.AnnotationType = annType + c.Arguments = args + return &c } func (v *GoVisitor) VisitArrayType(at *java.ArrayType, p any) java.J { - at = at.WithPrefix(v.self().VisitSpace(at.Prefix, p)) - at = at.WithMarkers(v.visitMarkers(at.Markers, p)) - at.Dimension.Before = v.self().VisitSpace(at.Dimension.Before, p) - at.Dimension.Element = v.self().VisitSpace(at.Dimension.Element, p) - at.ElementType = visitExpression(v, at.ElementType, p) - return at + prefix := v.self().VisitSpace(at.Prefix, p) + markers := v.visitMarkers(at.Markers, p) + dimBefore := v.self().VisitSpace(at.Dimension.Before, p) + dimElem := v.self().VisitSpace(at.Dimension.Element, p) + elemType := visitExpression(v, at.ElementType, p) + if java.SpaceEqual(prefix, at.Prefix) && java.MarkersEqual(markers, at.Markers) && + java.SpaceEqual(dimBefore, at.Dimension.Before) && java.SpaceEqual(dimElem, at.Dimension.Element) && + elemType == at.ElementType { + return at + } + c := *at + c.Prefix = prefix + c.Markers = markers + c.Dimension.Before = dimBefore + c.Dimension.Element = dimElem + c.ElementType = elemType + return &c } func (v *GoVisitor) VisitGoArrayType(at *golang.ArrayType, p any) java.J { - at = at.WithPrefix(v.self().VisitSpace(at.Prefix, p)) - at = at.WithMarkers(v.visitMarkers(at.Markers, p)) - at.Length.Element = visitExpression(v, at.Length.Element, p) - at.Length.After = v.self().VisitSpace(at.Length.After, p) - at.ElementType = visitExpression(v, at.ElementType, p) - return at + prefix := v.self().VisitSpace(at.Prefix, p) + markers := v.visitMarkers(at.Markers, p) + lenElem := visitExpression(v, at.Length.Element, p) + lenAfter := v.self().VisitSpace(at.Length.After, p) + elemType := visitExpression(v, at.ElementType, p) + if java.SpaceEqual(prefix, at.Prefix) && java.MarkersEqual(markers, at.Markers) && + lenElem == at.Length.Element && java.SpaceEqual(lenAfter, at.Length.After) && + elemType == at.ElementType { + return at + } + c := *at + c.Prefix = prefix + c.Markers = markers + c.Length.Element = lenElem + c.Length.After = lenAfter + c.ElementType = elemType + return &c } func (v *GoVisitor) VisitParentheses(paren *java.Parentheses, p any) java.J { - paren = paren.WithPrefix(v.self().VisitSpace(paren.Prefix, p)) - paren = paren.WithMarkers(v.visitMarkers(paren.Markers, p)) - paren.Tree.Element = visitExpression(v, paren.Tree.Element, p) - paren.Tree.After = v.self().VisitSpace(paren.Tree.After, p) - return paren + prefix := v.self().VisitSpace(paren.Prefix, p) + markers := v.visitMarkers(paren.Markers, p) + treeElem := visitExpression(v, paren.Tree.Element, p) + treeAfter := v.self().VisitSpace(paren.Tree.After, p) + if java.SpaceEqual(prefix, paren.Prefix) && java.MarkersEqual(markers, paren.Markers) && + treeElem == paren.Tree.Element && java.SpaceEqual(treeAfter, paren.Tree.After) { + return paren + } + c := *paren + c.Prefix = prefix + c.Markers = markers + c.Tree.Element = treeElem + c.Tree.After = treeAfter + return &c } func (v *GoVisitor) VisitTypeCast(tc *java.TypeCast, p any) java.J { - tc = tc.WithPrefix(v.self().VisitSpace(tc.Prefix, p)) - tc = tc.WithMarkers(v.visitMarkers(tc.Markers, p)) - tc.Expr = visitExpression(v, tc.Expr, p) - if tc.Clazz != nil { - tc.Clazz = visitAndCast[*java.ControlParentheses](v, tc.Clazz, p) + prefix := v.self().VisitSpace(tc.Prefix, p) + markers := v.visitMarkers(tc.Markers, p) + expr := visitExpression(v, tc.Expr, p) + clazz := tc.Clazz + if clazz != nil { + clazz = visitAndCast[*java.ControlParentheses](v, clazz, p) + } + if java.SpaceEqual(prefix, tc.Prefix) && java.MarkersEqual(markers, tc.Markers) && + expr == tc.Expr && clazz == tc.Clazz { + return tc } - return tc + c := *tc + c.Prefix = prefix + c.Markers = markers + c.Expr = expr + c.Clazz = clazz + return &c } func (v *GoVisitor) VisitControlParentheses(cp *java.ControlParentheses, p any) java.J { - cp = cp.WithPrefix(v.self().VisitSpace(cp.Prefix, p)) - cp = cp.WithMarkers(v.visitMarkers(cp.Markers, p)) - cp.Tree.Element = visitExpression(v, cp.Tree.Element, p) - cp.Tree.After = v.self().VisitSpace(cp.Tree.After, p) - return cp + prefix := v.self().VisitSpace(cp.Prefix, p) + markers := v.visitMarkers(cp.Markers, p) + treeElem := visitExpression(v, cp.Tree.Element, p) + treeAfter := v.self().VisitSpace(cp.Tree.After, p) + if java.SpaceEqual(prefix, cp.Prefix) && java.MarkersEqual(markers, cp.Markers) && + treeElem == cp.Tree.Element && java.SpaceEqual(treeAfter, cp.Tree.After) { + return cp + } + c := *cp + c.Prefix = prefix + c.Markers = markers + c.Tree.Element = treeElem + c.Tree.After = treeAfter + return &c } func (v *GoVisitor) VisitArrayAccess(aa *java.ArrayAccess, p any) java.J { - aa = aa.WithPrefix(v.self().VisitSpace(aa.Prefix, p)) - aa = aa.WithMarkers(v.visitMarkers(aa.Markers, p)) - aa.Indexed = visitExpression(v, aa.Indexed, p) - if aa.Dimension != nil { - aa.Dimension = visitAndCast[*java.ArrayDimension](v, aa.Dimension, p) + prefix := v.self().VisitSpace(aa.Prefix, p) + markers := v.visitMarkers(aa.Markers, p) + indexed := visitExpression(v, aa.Indexed, p) + dim := aa.Dimension + if dim != nil { + dim = visitAndCast[*java.ArrayDimension](v, dim, p) + } + if java.SpaceEqual(prefix, aa.Prefix) && java.MarkersEqual(markers, aa.Markers) && + indexed == aa.Indexed && dim == aa.Dimension { + return aa } - return aa + c := *aa + c.Prefix = prefix + c.Markers = markers + c.Indexed = indexed + c.Dimension = dim + return &c } func (v *GoVisitor) VisitParameterizedType(pt *java.ParameterizedType, p any) java.J { - pt = pt.WithPrefix(v.self().VisitSpace(pt.Prefix, p)) - pt = pt.WithMarkers(v.visitMarkers(pt.Markers, p)) - if pt.Clazz != nil { - pt.Clazz = visitExpression(v, pt.Clazz, p) + prefix := v.self().VisitSpace(pt.Prefix, p) + markers := v.visitMarkers(pt.Markers, p) + clazz := pt.Clazz + if clazz != nil { + clazz = visitExpression(v, clazz, p) } - if pt.TypeParameters != nil { - pt.TypeParameters.Before = v.self().VisitSpace(pt.TypeParameters.Before, p) - pt.TypeParameters.Elements = visitRightPaddedList(v, pt.TypeParameters.Elements, p) + tps := pt.TypeParameters + if tps != nil { + before := v.self().VisitSpace(tps.Before, p) + elems := visitRightPaddedList(v, tps.Elements, p) + if !java.SpaceEqual(before, tps.Before) || !java.SameSlice(elems, tps.Elements) { + c := *tps + c.Before = before + c.Elements = elems + tps = &c + } + } + if java.SpaceEqual(prefix, pt.Prefix) && java.MarkersEqual(markers, pt.Markers) && + clazz == pt.Clazz && tps == pt.TypeParameters { + return pt } - return pt + c := *pt + c.Prefix = prefix + c.Markers = markers + c.Clazz = clazz + c.TypeParameters = tps + return &c } func (v *GoVisitor) VisitIndexList(il *golang.IndexList, p any) java.J { - il = il.WithPrefix(v.self().VisitSpace(il.Prefix, p)) - il = il.WithMarkers(v.visitMarkers(il.Markers, p)) - if il.Target != nil { - il.Target = visitExpression(v, il.Target, p) + prefix := v.self().VisitSpace(il.Prefix, p) + markers := v.visitMarkers(il.Markers, p) + target := il.Target + if target != nil { + target = visitExpression(v, target, p) } - il.Indices.Before = v.self().VisitSpace(il.Indices.Before, p) - il.Indices.Elements = visitRightPaddedList(v, il.Indices.Elements, p) - return il + indicesBefore := v.self().VisitSpace(il.Indices.Before, p) + indicesElems := visitRightPaddedList(v, il.Indices.Elements, p) + if java.SpaceEqual(prefix, il.Prefix) && java.MarkersEqual(markers, il.Markers) && + target == il.Target && + java.SpaceEqual(indicesBefore, il.Indices.Before) && java.SameSlice(indicesElems, il.Indices.Elements) { + return il + } + c := *il + c.Prefix = prefix + c.Markers = markers + c.Target = target + c.Indices.Before = indicesBefore + c.Indices.Elements = indicesElems + return &c } func (v *GoVisitor) VisitArrayDimension(ad *java.ArrayDimension, p any) java.J { - ad = ad.WithPrefix(v.self().VisitSpace(ad.Prefix, p)) - ad = ad.WithMarkers(v.visitMarkers(ad.Markers, p)) - ad.Index.Element = visitExpression(v, ad.Index.Element, p) - ad.Index.After = v.self().VisitSpace(ad.Index.After, p) - return ad + prefix := v.self().VisitSpace(ad.Prefix, p) + markers := v.visitMarkers(ad.Markers, p) + idxElem := visitExpression(v, ad.Index.Element, p) + idxAfter := v.self().VisitSpace(ad.Index.After, p) + if java.SpaceEqual(prefix, ad.Prefix) && java.MarkersEqual(markers, ad.Markers) && + idxElem == ad.Index.Element && java.SpaceEqual(idxAfter, ad.Index.After) { + return ad + } + c := *ad + c.Prefix = prefix + c.Markers = markers + c.Index.Element = idxElem + c.Index.After = idxAfter + return &c } -func (v *GoVisitor) VisitComposite(c *golang.Composite, p any) java.J { - c = c.WithPrefix(v.self().VisitSpace(c.Prefix, p)) - c = c.WithMarkers(v.visitMarkers(c.Markers, p)) - if c.TypeExpr != nil { - c.TypeExpr = visitExpression(v, c.TypeExpr, p) +func (v *GoVisitor) VisitComposite(comp *golang.Composite, p any) java.J { + prefix := v.self().VisitSpace(comp.Prefix, p) + markers := v.visitMarkers(comp.Markers, p) + typeExpr := comp.TypeExpr + if typeExpr != nil { + typeExpr = visitExpression(v, typeExpr, p) + } + elemsBefore := v.self().VisitSpace(comp.Elements.Before, p) + elemsElems := visitRightPaddedList(v, comp.Elements.Elements, p) + if java.SpaceEqual(prefix, comp.Prefix) && java.MarkersEqual(markers, comp.Markers) && + typeExpr == comp.TypeExpr && + java.SpaceEqual(elemsBefore, comp.Elements.Before) && java.SameSlice(elemsElems, comp.Elements.Elements) { + return comp } - c.Elements.Before = v.self().VisitSpace(c.Elements.Before, p) - c.Elements.Elements = visitRightPaddedList(v, c.Elements.Elements, p) - return c + c := *comp + c.Prefix = prefix + c.Markers = markers + c.TypeExpr = typeExpr + c.Elements.Before = elemsBefore + c.Elements.Elements = elemsElems + return &c } func (v *GoVisitor) VisitKeyValue(kv *golang.KeyValue, p any) java.J { - kv = kv.WithPrefix(v.self().VisitSpace(kv.Prefix, p)) - kv = kv.WithMarkers(v.visitMarkers(kv.Markers, p)) - kv.Key = visitExpression(v, kv.Key, p) - kv.Value.Before = v.self().VisitSpace(kv.Value.Before, p) - kv.Value.Element = visitExpression(v, kv.Value.Element, p) - return kv + prefix := v.self().VisitSpace(kv.Prefix, p) + markers := v.visitMarkers(kv.Markers, p) + key := visitExpression(v, kv.Key, p) + valueBefore := v.self().VisitSpace(kv.Value.Before, p) + valueElem := visitExpression(v, kv.Value.Element, p) + if java.SpaceEqual(prefix, kv.Prefix) && java.MarkersEqual(markers, kv.Markers) && + key == kv.Key && + java.SpaceEqual(valueBefore, kv.Value.Before) && valueElem == kv.Value.Element { + return kv + } + c := *kv + c.Prefix = prefix + c.Markers = markers + c.Key = key + c.Value.Before = valueBefore + c.Value.Element = valueElem + return &c } func (v *GoVisitor) VisitSlice(s *golang.Slice, p any) java.J { - s = s.WithPrefix(v.self().VisitSpace(s.Prefix, p)) - s = s.WithMarkers(v.visitMarkers(s.Markers, p)) - s.Indexed = visitExpression(v, s.Indexed, p) - s.OpenBracket = v.self().VisitSpace(s.OpenBracket, p) - s.Low.Element = visitExpression(v, s.Low.Element, p) - s.Low.After = v.self().VisitSpace(s.Low.After, p) - s.High.Element = visitExpression(v, s.High.Element, p) - s.High.After = v.self().VisitSpace(s.High.After, p) - if s.Max != nil { - s.Max = visitExpression(v, s.Max, p) + prefix := v.self().VisitSpace(s.Prefix, p) + markers := v.visitMarkers(s.Markers, p) + indexed := visitExpression(v, s.Indexed, p) + openBracket := v.self().VisitSpace(s.OpenBracket, p) + lowElem := visitExpression(v, s.Low.Element, p) + lowAfter := v.self().VisitSpace(s.Low.After, p) + highElem := visitExpression(v, s.High.Element, p) + highAfter := v.self().VisitSpace(s.High.After, p) + max := s.Max + if max != nil { + max = visitExpression(v, max, p) + } + closeBracket := v.self().VisitSpace(s.CloseBracket, p) + if java.SpaceEqual(prefix, s.Prefix) && java.MarkersEqual(markers, s.Markers) && + indexed == s.Indexed && java.SpaceEqual(openBracket, s.OpenBracket) && + lowElem == s.Low.Element && java.SpaceEqual(lowAfter, s.Low.After) && + highElem == s.High.Element && java.SpaceEqual(highAfter, s.High.After) && + max == s.Max && java.SpaceEqual(closeBracket, s.CloseBracket) { + return s } - s.CloseBracket = v.self().VisitSpace(s.CloseBracket, p) - return s + c := *s + c.Prefix = prefix + c.Markers = markers + c.Indexed = indexed + c.OpenBracket = openBracket + c.Low.Element = lowElem + c.Low.After = lowAfter + c.High.Element = highElem + c.High.After = highAfter + c.Max = max + c.CloseBracket = closeBracket + return &c } func (v *GoVisitor) VisitMapType(mt *golang.MapType, p any) java.J { - mt = mt.WithPrefix(v.self().VisitSpace(mt.Prefix, p)) - mt = mt.WithMarkers(v.visitMarkers(mt.Markers, p)) - mt.OpenBracket = v.self().VisitSpace(mt.OpenBracket, p) - mt.Key.Element = visitExpression(v, mt.Key.Element, p) - mt.Key.After = v.self().VisitSpace(mt.Key.After, p) - mt.Value = visitExpression(v, mt.Value, p) - return mt + prefix := v.self().VisitSpace(mt.Prefix, p) + markers := v.visitMarkers(mt.Markers, p) + openBracket := v.self().VisitSpace(mt.OpenBracket, p) + keyElem := visitExpression(v, mt.Key.Element, p) + keyAfter := v.self().VisitSpace(mt.Key.After, p) + value := visitExpression(v, mt.Value, p) + if java.SpaceEqual(prefix, mt.Prefix) && java.MarkersEqual(markers, mt.Markers) && + java.SpaceEqual(openBracket, mt.OpenBracket) && + keyElem == mt.Key.Element && java.SpaceEqual(keyAfter, mt.Key.After) && + value == mt.Value { + return mt + } + c := *mt + c.Prefix = prefix + c.Markers = markers + c.OpenBracket = openBracket + c.Key.Element = keyElem + c.Key.After = keyAfter + c.Value = value + return &c } func (v *GoVisitor) VisitStatementExpression(se *golang.StatementExpression, p any) java.J { - se = se.WithPrefix(v.self().VisitSpace(se.Prefix, p)) - se = se.WithMarkers(v.visitMarkers(se.Markers, p)) - result := v.self().Visit(se.Statement, p) - if stmt, ok := result.(java.Statement); ok { - se.Statement = stmt + prefix := v.self().VisitSpace(se.Prefix, p) + markers := v.visitMarkers(se.Markers, p) + stmt := se.Statement + if result, ok := v.self().Visit(se.Statement, p).(java.Statement); ok { + stmt = result + } + if java.SpaceEqual(prefix, se.Prefix) && java.MarkersEqual(markers, se.Markers) && stmt == se.Statement { + return se } - return se + c := *se + c.Prefix = prefix + c.Markers = markers + c.Statement = stmt + return &c } func (v *GoVisitor) VisitPointerType(pt *golang.PointerType, p any) java.J { - pt = pt.WithPrefix(v.self().VisitSpace(pt.Prefix, p)) - pt = pt.WithMarkers(v.visitMarkers(pt.Markers, p)) - pt.Elem = visitExpression(v, pt.Elem, p) - return pt + prefix := v.self().VisitSpace(pt.Prefix, p) + markers := v.visitMarkers(pt.Markers, p) + elem := visitExpression(v, pt.Elem, p) + if java.SpaceEqual(prefix, pt.Prefix) && java.MarkersEqual(markers, pt.Markers) && elem == pt.Elem { + return pt + } + c := *pt + c.Prefix = prefix + c.Markers = markers + c.Elem = elem + return &c } func (v *GoVisitor) VisitChannel(ch *golang.Channel, p any) java.J { - ch = ch.WithPrefix(v.self().VisitSpace(ch.Prefix, p)) - ch = ch.WithMarkers(v.visitMarkers(ch.Markers, p)) - ch.Value = visitExpression(v, ch.Value, p) - return ch + prefix := v.self().VisitSpace(ch.Prefix, p) + markers := v.visitMarkers(ch.Markers, p) + value := visitExpression(v, ch.Value, p) + if java.SpaceEqual(prefix, ch.Prefix) && java.MarkersEqual(markers, ch.Markers) && value == ch.Value { + return ch + } + c := *ch + c.Prefix = prefix + c.Markers = markers + c.Value = value + return &c } func (v *GoVisitor) VisitFuncType(ft *golang.FuncType, p any) java.J { - ft = ft.WithPrefix(v.self().VisitSpace(ft.Prefix, p)) - ft = ft.WithMarkers(v.visitMarkers(ft.Markers, p)) - ft.Parameters.Before = v.self().VisitSpace(ft.Parameters.Before, p) - ft.Parameters.Elements = visitRightPaddedList(v, ft.Parameters.Elements, p) - if ft.ReturnType != nil { - ft.ReturnType = visitExpression(v, ft.ReturnType, p) + prefix := v.self().VisitSpace(ft.Prefix, p) + markers := v.visitMarkers(ft.Markers, p) + paramsBefore := v.self().VisitSpace(ft.Parameters.Before, p) + paramsElems := visitRightPaddedList(v, ft.Parameters.Elements, p) + returnType := ft.ReturnType + if returnType != nil { + returnType = visitExpression(v, returnType, p) } - return ft + if java.SpaceEqual(prefix, ft.Prefix) && java.MarkersEqual(markers, ft.Markers) && + java.SpaceEqual(paramsBefore, ft.Parameters.Before) && java.SameSlice(paramsElems, ft.Parameters.Elements) && + returnType == ft.ReturnType { + return ft + } + c := *ft + c.Prefix = prefix + c.Markers = markers + c.Parameters.Before = paramsBefore + c.Parameters.Elements = paramsElems + c.ReturnType = returnType + return &c } func (v *GoVisitor) VisitTypeList(tl *golang.TypeList, p any) java.J { - tl = tl.WithPrefix(v.self().VisitSpace(tl.Prefix, p)) - tl = tl.WithMarkers(v.visitMarkers(tl.Markers, p)) - tl.Types.Before = v.self().VisitSpace(tl.Types.Before, p) - tl.Types.Elements = visitRightPaddedList(v, tl.Types.Elements, p) - return tl + prefix := v.self().VisitSpace(tl.Prefix, p) + markers := v.visitMarkers(tl.Markers, p) + typesBefore := v.self().VisitSpace(tl.Types.Before, p) + typesElems := visitRightPaddedList(v, tl.Types.Elements, p) + if java.SpaceEqual(prefix, tl.Prefix) && java.MarkersEqual(markers, tl.Markers) && + java.SpaceEqual(typesBefore, tl.Types.Before) && java.SameSlice(typesElems, tl.Types.Elements) { + return tl + } + c := *tl + c.Prefix = prefix + c.Markers = markers + c.Types.Before = typesBefore + c.Types.Elements = typesElems + return &c } func (v *GoVisitor) VisitUnion(u *golang.Union, p any) java.J { - u = u.WithPrefix(v.self().VisitSpace(u.Prefix, p)) - u = u.WithMarkers(v.visitMarkers(u.Markers, p)) - u.Types = visitRightPaddedExpressionList(v, u.Types, p) - return u + prefix := v.self().VisitSpace(u.Prefix, p) + markers := v.visitMarkers(u.Markers, p) + types := visitRightPaddedExpressionList(v, u.Types, p) + if java.SpaceEqual(prefix, u.Prefix) && java.MarkersEqual(markers, u.Markers) && java.SameSlice(types, u.Types) { + return u + } + c := *u + c.Prefix = prefix + c.Markers = markers + c.Types = types + return &c } func (v *GoVisitor) VisitUnderlyingType(ut *golang.UnderlyingType, p any) java.J { - ut = ut.WithPrefix(v.self().VisitSpace(ut.Prefix, p)) - ut = ut.WithMarkers(v.visitMarkers(ut.Markers, p)) - ut.Element = visitExpression(v, ut.Element, p) - return ut + prefix := v.self().VisitSpace(ut.Prefix, p) + markers := v.visitMarkers(ut.Markers, p) + elem := visitExpression(v, ut.Element, p) + if java.SpaceEqual(prefix, ut.Prefix) && java.MarkersEqual(markers, ut.Markers) && elem == ut.Element { + return ut + } + c := *ut + c.Prefix = prefix + c.Markers = markers + c.Element = elem + return &c } func (v *GoVisitor) VisitTypeDecl(td *golang.TypeDecl, p any) java.J { - td = td.WithPrefix(v.self().VisitSpace(td.Prefix, p)) - td = td.WithMarkers(v.visitMarkers(td.Markers, p)) - if len(td.LeadingAnnotations) > 0 { - anns := make([]*java.Annotation, 0, len(td.LeadingAnnotations)) - for _, a := range td.LeadingAnnotations { - visited := v.self().Visit(a, p) - if visited == nil { - continue - } - anns = append(anns, visited.(*java.Annotation)) - } - td = td.WithLeadingAnnotations(anns) + prefix := v.self().VisitSpace(td.Prefix, p) + markers := v.visitMarkers(td.Markers, p) + anns := visitAnnotationList(v, td.LeadingAnnotations, p) + name := td.Name + if name != nil { + name = visitAndCast[*java.Identifier](v, name, p) } - if td.Name != nil { - td.Name = visitAndCast[*java.Identifier](v, td.Name, p) + tps := td.TypeParameters + if tps != nil { + tps = visitAndCast[*java.TypeParameters](v, tps, p) } - if td.TypeParameters != nil { - td = td.WithTypeParameters(visitAndCast[*java.TypeParameters](v, td.TypeParameters, p)) + assign := td.Assign + if assign != nil { + before := v.self().VisitSpace(assign.Before, p) + if !java.SpaceEqual(before, assign.Before) { + c := *assign + c.Before = before + assign = &c + } } - if td.Assign != nil { - assign := *td.Assign - assign.Before = v.self().VisitSpace(assign.Before, p) - td.Assign = &assign + definition := td.Definition + if definition != nil { + definition = visitExpression(v, definition, p) } - if td.Definition != nil { - td.Definition = visitExpression(v, td.Definition, p) + specs := td.Specs + if specs != nil { + before := v.self().VisitSpace(specs.Before, p) + elems := visitRightPaddedList(v, specs.Elements, p) + if !java.SpaceEqual(before, specs.Before) || !java.SameSlice(elems, specs.Elements) { + c := *specs + c.Before = before + c.Elements = elems + specs = &c + } } - if td.Specs != nil { - specs := *td.Specs - specs.Before = v.self().VisitSpace(specs.Before, p) - specs.Elements = visitRightPaddedList(v, specs.Elements, p) - td.Specs = &specs + if java.SpaceEqual(prefix, td.Prefix) && java.MarkersEqual(markers, td.Markers) && + java.SameSlice(anns, td.LeadingAnnotations) && name == td.Name && tps == td.TypeParameters && + assign == td.Assign && definition == td.Definition && specs == td.Specs { + return td } - return td + c := *td + c.Prefix = prefix + c.Markers = markers + c.LeadingAnnotations = anns + c.Name = name + c.TypeParameters = tps + c.Assign = assign + c.Definition = definition + c.Specs = specs + return &c } func (v *GoVisitor) VisitStructType(st *golang.StructType, p any) java.J { - st = st.WithPrefix(v.self().VisitSpace(st.Prefix, p)) - st = st.WithMarkers(v.visitMarkers(st.Markers, p)) - if st.Body != nil { - st.Body = visitAndCast[*java.Block](v, st.Body, p) + prefix := v.self().VisitSpace(st.Prefix, p) + markers := v.visitMarkers(st.Markers, p) + body := st.Body + if body != nil { + body = visitAndCast[*java.Block](v, body, p) + } + if java.SpaceEqual(prefix, st.Prefix) && java.MarkersEqual(markers, st.Markers) && body == st.Body { + return st } - return st + c := *st + c.Prefix = prefix + c.Markers = markers + c.Body = body + return &c } func (v *GoVisitor) VisitInterfaceType(it *golang.InterfaceType, p any) java.J { - it = it.WithPrefix(v.self().VisitSpace(it.Prefix, p)) - it = it.WithMarkers(v.visitMarkers(it.Markers, p)) - if it.Body != nil { - it.Body = visitAndCast[*java.Block](v, it.Body, p) + prefix := v.self().VisitSpace(it.Prefix, p) + markers := v.visitMarkers(it.Markers, p) + body := it.Body + if body != nil { + body = visitAndCast[*java.Block](v, body, p) } - return it + if java.SpaceEqual(prefix, it.Prefix) && java.MarkersEqual(markers, it.Markers) && body == it.Body { + return it + } + c := *it + c.Prefix = prefix + c.Markers = markers + c.Body = body + return &c } func (v *GoVisitor) VisitMultiAssignment(ma *golang.MultiAssignment, p any) java.J { - ma = ma.WithPrefix(v.self().VisitSpace(ma.Prefix, p)) - ma = ma.WithMarkers(v.visitMarkers(ma.Markers, p)) - ma.Variables = visitRightPaddedExpressionList(v, ma.Variables, p) - ma.Operator.Before = v.self().VisitSpace(ma.Operator.Before, p) - ma.Values = visitRightPaddedExpressionList(v, ma.Values, p) - return ma + prefix := v.self().VisitSpace(ma.Prefix, p) + markers := v.visitMarkers(ma.Markers, p) + variables := visitRightPaddedExpressionList(v, ma.Variables, p) + opBefore := v.self().VisitSpace(ma.Operator.Before, p) + values := visitRightPaddedExpressionList(v, ma.Values, p) + if java.SpaceEqual(prefix, ma.Prefix) && java.MarkersEqual(markers, ma.Markers) && + java.SameSlice(variables, ma.Variables) && java.SpaceEqual(opBefore, ma.Operator.Before) && + java.SameSlice(values, ma.Values) { + return ma + } + c := *ma + c.Prefix = prefix + c.Markers = markers + c.Variables = variables + c.Operator.Before = opBefore + c.Values = values + return &c } func (v *GoVisitor) VisitCommClause(cc *golang.CommClause, p any) java.J { - cc = cc.WithPrefix(v.self().VisitSpace(cc.Prefix, p)) - cc = cc.WithMarkers(v.visitMarkers(cc.Markers, p)) - if cc.Comm != nil { - cc.Comm = visitAndCast[java.Statement](v, cc.Comm, p) + prefix := v.self().VisitSpace(cc.Prefix, p) + markers := v.visitMarkers(cc.Markers, p) + comm := cc.Comm + if comm != nil { + comm = visitAndCast[java.Statement](v, comm, p) + } + colon := v.self().VisitSpace(cc.Colon, p) + body := visitRightPaddedList(v, cc.Body, p) + if java.SpaceEqual(prefix, cc.Prefix) && java.MarkersEqual(markers, cc.Markers) && + comm == cc.Comm && java.SpaceEqual(colon, cc.Colon) && java.SameSlice(body, cc.Body) { + return cc } - cc.Colon = v.self().VisitSpace(cc.Colon, p) - cc.Body = visitRightPaddedList(v, cc.Body, p) - return cc + c := *cc + c.Prefix = prefix + c.Markers = markers + c.Comm = comm + c.Colon = colon + c.Body = body + return &c } func (v *GoVisitor) VisitSpace(space java.Space, p any) java.Space { @@ -1180,20 +1808,108 @@ func visitAndCast[T java.Tree](v *GoVisitor, t java.Tree, p any) T { return result.(T) } +// visitAnnotationList visits a slice of annotations, preserving slice identity: +// it returns the original slice when no annotation changed (and none deleted), +// allocating only on the first change. Mirrors visitRightPaddedList's idiom. +func visitAnnotationList(v *GoVisitor, list []*java.Annotation, p any) []*java.Annotation { + var result []*java.Annotation + materialize := func(i int) { + if result == nil { + result = make([]*java.Annotation, 0, len(list)) + result = append(result, list[:i]...) + } + } + for i := range list { + a := list[i] + visited := v.self().Visit(a, p) + if visited == nil { + materialize(i) + continue + } + na := visited.(*java.Annotation) + if na == a { + if result != nil { + result = append(result, na) + } + continue + } + materialize(i) + result = append(result, na) + } + if result == nil { + return list + } + return result +} + +// visitGoModValueList visits a slice of go.mod values, preserving slice identity: +// it returns the original slice when nothing changed, allocating only on the +// first change/deletion. +func visitGoModValueList(v *GoVisitor, list []*golang.GoModValue, p any) []*golang.GoModValue { + var result []*golang.GoModValue + materialize := func(i int) { + if result == nil { + result = make([]*golang.GoModValue, 0, len(list)) + result = append(result, list[:i]...) + } + } + for i := range list { + val := list[i] + visited := v.self().Visit(val, p) + if visited == nil { + materialize(i) + continue + } + nv := visited.(*golang.GoModValue) + if nv == val { + if result != nil { + result = append(result, nv) + } + continue + } + materialize(i) + result = append(result, nv) + } + if result == nil { + return list + } + return result +} + // visitGoModStatementList visits a right-padded list of go.mod statements. // GoModStatement is a java.Tree (not java.J), so the J-constrained // visitRightPaddedList helper can't be reused here. func visitGoModStatementList(v *GoVisitor, list []java.RightPadded[golang.GoModStatement], p any) []java.RightPadded[golang.GoModStatement] { - result := make([]java.RightPadded[golang.GoModStatement], 0, len(list)) - for _, rp := range list { + var result []java.RightPadded[golang.GoModStatement] + materialize := func(i int) { + if result == nil { + result = make([]java.RightPadded[golang.GoModStatement], 0, len(list)) + result = append(result, list[:i]...) + } + } + for i := range list { + rp := list[i] visited := v.self().Visit(rp.Element, p) + newAfter := v.self().VisitSpace(rp.After, p) if visited == nil { + materialize(i) continue } - rp.Element = visited.(golang.GoModStatement) - rp.After = v.self().VisitSpace(rp.After, p) + ne := visited.(golang.GoModStatement) + if ne == rp.Element && java.SpaceEqual(newAfter, rp.After) { + if result != nil { + result = append(result, rp) + } + continue + } + materialize(i) + rp.Element = ne + rp.After = newAfter result = append(result, rp) } + if result == nil { + return list + } return result } @@ -1206,29 +1922,72 @@ func visitExpression(v *GoVisitor, expr java.Expression, p any) java.Expression } func visitRightPaddedExpressionList(v *GoVisitor, list []java.RightPadded[java.Expression], p any) []java.RightPadded[java.Expression] { - result := make([]java.RightPadded[java.Expression], 0, len(list)) - for _, rp := range list { + var result []java.RightPadded[java.Expression] + materialize := func(i int) { + if result == nil { + result = make([]java.RightPadded[java.Expression], 0, len(list)) + result = append(result, list[:i]...) + } + } + for i := range list { + rp := list[i] visited := v.self().Visit(rp.Element, p) + newAfter := v.self().VisitSpace(rp.After, p) if visited == nil { + materialize(i) continue } - rp.Element = visited.(java.Expression) - rp.After = v.self().VisitSpace(rp.After, p) + ne := visited.(java.Expression) + if ne == rp.Element && java.SpaceEqual(newAfter, rp.After) { + if result != nil { + result = append(result, rp) + } + continue + } + materialize(i) + rp.Element = ne + rp.After = newAfter result = append(result, rp) } + if result == nil { + return list + } return result } func visitRightPaddedList[T java.J](v *GoVisitor, list []java.RightPadded[T], p any) []java.RightPadded[T] { - result := make([]java.RightPadded[T], 0, len(list)) - for _, rp := range list { + // Preserve slice identity: return the original `list` when no element + // changed, so the enclosing withX guard (java.SameSlice) returns the same + // node. Only allocate once the first change (mutation/deletion) is seen. + var result []java.RightPadded[T] + materialize := func(i int) { + if result == nil { + result = make([]java.RightPadded[T], 0, len(list)) + result = append(result, list[:i]...) + } + } + for i := range list { + rp := list[i] visited := v.self().Visit(rp.Element, p) + newAfter := v.self().VisitSpace(rp.After, p) if visited == nil { + materialize(i) + continue + } + ne := visited.(T) + if any(ne) == any(rp.Element) && java.SpaceEqual(newAfter, rp.After) { + if result != nil { + result = append(result, rp) + } continue } - rp.Element = visited.(T) - rp.After = v.self().VisitSpace(rp.After, p) + materialize(i) + rp.Element = ne + rp.After = newAfter result = append(result, rp) } + if result == nil { + return list + } return result } diff --git a/rewrite-go/test/build_tags_test.go b/rewrite-go/test/build_tags_test.go index e5a50c7477d..c080208aca3 100644 --- a/rewrite-go/test/build_tags_test.go +++ b/rewrite-go/test/build_tags_test.go @@ -22,21 +22,25 @@ import ( "testing" "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" ) // parsedNames returns the file names included by ParsePackage for the // given build context — the names of files that survived `//go:build` -// and filename-suffix constraint evaluation. +// and filename-suffix constraint evaluation. These inputs are all +// well-formed, so every included file is expected to be a CompilationUnit. func parsedNames(t *testing.T, buildCtx build.Context, files []parser.FileInput) []string { t.Helper() p := parser.NewGoParserWithBuildContext(buildCtx) - cus, err := p.ParsePackage(files) - if err != nil { - t.Fatalf("ParsePackage: %v", err) - } - out := make([]string, 0, len(cus)) - for _, cu := range cus { - out = append(out, cu.SourcePath) + out := make([]string, 0, len(files)) + for _, sf := range p.ParsePackage(files) { + switch v := sf.(type) { + case *golang.CompilationUnit: + out = append(out, v.SourcePath) + case *java.ParseError: + t.Fatalf("unexpected parse error for %s: %v", v.SourcePath, v.Cause()) + } } sort.Strings(out) return out diff --git a/rewrite-go/test/file_imports_test.go b/rewrite-go/test/file_imports_test.go new file mode 100644 index 00000000000..5f606cf7ac8 --- /dev/null +++ b/rewrite-go/test/file_imports_test.go @@ -0,0 +1,57 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test + +import ( + "sort" + "testing" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" +) + +// TestFileImports verifies imports-only extraction works regardless of build +// constraints (the file here is windows-gated) and handles aliased, blank, +// and grouped imports. +func TestFileImports(t *testing.T) { + src := "//go:build windows\n\n" + + "package sys\n\n" + + "import (\n" + + "\t\"fmt\"\n" + + "\t_ \"embed\"\n" + + "\talias \"golang.org/x/sys/windows\"\n" + + ")\n\n" + + "import \"strings\"\n" + + got := parser.FileImports(src) + sort.Strings(got) + want := []string{"embed", "fmt", "golang.org/x/sys/windows", "strings"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +// TestFileImportsMalformed returns nil (not a panic) for unparseable source. +func TestFileImportsMalformed(t *testing.T) { + if got := parser.FileImports("package @@@ broken"); got != nil { + t.Errorf("expected nil for malformed source, got %v", got) + } +} diff --git a/rewrite-go/test/immutability_test.go b/rewrite-go/test/immutability_test.go new file mode 100644 index 00000000000..af50a035e92 --- /dev/null +++ b/rewrite-go/test/immutability_test.go @@ -0,0 +1,84 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test + +import ( + "testing" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" + "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" + "github.com/openrewrite/rewrite/rewrite-go/pkg/visitor" +) + +// renamer changes every identifier named "x" to "y" — a minimal change-visit. +type renamer struct{ visitor.GoVisitor } + +func (r *renamer) VisitIdentifier(id *java.Identifier, p any) java.J { + if id.Name == "x" { + return id.WithName("y") + } + return r.GoVisitor.VisitIdentifier(id, p) +} + +// immutabilitySamples exercises a broad spread of node types. +var immutabilitySamples = map[string]string{ + "assign-if-unary": "package p\n\nfunc f() {\n\tx := 1\n\tif x == 1 {\n\t\tx = 2\n\t}\n\t_ = &x\n}\n", + "imports": "package p\n\nimport \"fmt\"\n\nfunc f() { fmt.Println(\"hi\") }\n", + "struct-method": "package p\n\ntype T struct{ A int }\n\nfunc (t T) M() int { return t.A }\n", + "for-range-chan": "package p\n\nfunc f(ch <-chan int) {\n\tfor i := 0; i < 3; i++ {\n\t\t_ = <-ch\n\t}\n}\n", + "composite": "package p\n\ntype T struct{ A int }\n\nfunc f() { _ = T{A: 1} }\n", +} + +// TestVisitDoesNotMutateOriginal is the immutability invariant: a change-visit +// must not mutate the shared input tree in place. With identity-preserving +// withX, any VisitX that assigns to a receiver field directly would corrupt +// the original — this guards against that. +func TestVisitDoesNotMutateOriginal(t *testing.T) { + for name, src := range immutabilitySamples { + cu, err := parser.NewGoParser().Parse("p.go", src) + if err != nil { + t.Fatalf("%s: parse: %v", name, err) + } + before := printer.Print(cu) + r := &renamer{} + visitor.Init(r) + r.Visit(cu, nil) + if after := printer.Print(cu); after != before { + t.Errorf("%s: ORIGINAL tree mutated in place\n--- was ---\n%s\n--- now ---\n%s", name, before, after) + } + } +} + +// TestNoOpVisitPreservesIdentity is the identity invariant: a visit that +// changes nothing must return the SAME pointer (so the engine's change +// detection reports "unchanged"). This is what keeps untouched files out of a +// recipe's results/patch. +func TestNoOpVisitPreservesIdentity(t *testing.T) { + for name, src := range immutabilitySamples { + cu, err := parser.NewGoParser().Parse("p.go", src) + if err != nil { + t.Fatalf("%s: parse: %v", name, err) + } + v := &visitor.GoVisitor{} + visitor.Init(v) + got := v.Visit(cu, nil) + if got != java.Tree(cu) { + t.Errorf("%s: no-op visit did not preserve identity (returned a new pointer)", name) + } + } +} diff --git a/rewrite-go/test/literal_value_test.go b/rewrite-go/test/literal_value_test.go new file mode 100644 index 00000000000..222631ea6d3 --- /dev/null +++ b/rewrite-go/test/literal_value_test.go @@ -0,0 +1,115 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test + +import ( + "strings" + "testing" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" + "github.com/openrewrite/rewrite/rewrite-go/pkg/visitor" +) + +type literalWalker struct { + visitor.GoVisitor + lits map[string]*java.Literal +} + +func (v *literalWalker) VisitLiteral(lit *java.Literal, p any) java.J { + v.lits[lit.Source] = lit + return v.GoVisitor.VisitLiteral(lit, p) +} + +// collectLiterals walks a compilation unit collecting every java.Literal, keyed +// by its Source (the original Go literal text). +func collectLiterals(cu *golang.CompilationUnit) map[string]*java.Literal { + w := &literalWalker{lits: map[string]*java.Literal{}} + visitor.Init(w) + w.Visit(cu, nil) + return w.lits +} + +// TestLiteralValueIsJavaCoercible verifies that Go literal syntax (hex, octal, +// binary, underscores, quoted runes, oversized int64) is normalized in +// Literal.Value into a form org.openrewrite's J.Literal can coerce — while +// Source keeps the original text (so printing is byte-identical). Pre-fix, these +// crashed V3's coerceLiteralValue with NumberFormatException, desyncing the RPC +// stream (observed as gin's spurious TypeCast NPE). +func TestLiteralValueIsJavaCoercible(t *testing.T) { + src := `package p + +func f() { + var _ = 0xf5 + var _ = 100_000 + var _ = 0o17 + var _ = 'a' + var _ byte = 200 + var _ int64 = 62135596800 + var _ = 3.14 +} +` + cu, err := parser.NewGoParser().Parse("p.go", src) + if err != nil { + t.Fatalf("parse: %v", err) + } + lits := collectLiterals(cu) + + cases := []struct { + source string + value string // expected Literal.Value (decimal / char) + }{ + {"0xf5", "245"}, + {"100_000", "100000"}, + {"0o17", "15"}, + {"'a'", "a"}, + {"200", "200"}, + {"62135596800", "62135596800"}, + } + for _, c := range cases { + lit, ok := lits[c.source] + if !ok { + t.Errorf("literal %q not found", c.source) + continue + } + got, _ := lit.Value.(string) + if got != c.value { + t.Errorf("%q: Value = %q, want %q", c.source, got, c.value) + } + if lit.Source != c.source { + t.Errorf("%q: Source = %q, want it preserved", c.source, lit.Source) + } + // No Go-specific syntax may leak into Value (what Java can't parse). + if strings.ContainsAny(got, "_'\"") || strings.HasPrefix(got, "0x") || strings.HasPrefix(got, "0o") { + t.Errorf("%q: Value %q still carries Go literal syntax", c.source, got) + } + } + + // int64 that overflows Java int must widen to long. + if lit := lits["62135596800"]; lit != nil { + if p, ok := lit.Type.(*java.JavaTypePrimitive); !ok || p.Keyword != "long" { + t.Errorf("62135596800: type = %v, want primitive long", lit.Type) + } + } + // A byte value > 127 can't fit Java's signed byte, so it widens to int. + if lit := lits["200"]; lit != nil { + if p, ok := lit.Type.(*java.JavaTypePrimitive); !ok || p.Keyword != "int" { + t.Errorf("byte 200: type = %v, want primitive int", lit.Type) + } + } +} diff --git a/rewrite-go/test/multi_file_package_test.go b/rewrite-go/test/multi_file_package_test.go index 7d09e7c3af5..6fa6bfb16b4 100644 --- a/rewrite-go/test/multi_file_package_test.go +++ b/rewrite-go/test/multi_file_package_test.go @@ -29,15 +29,20 @@ import ( // both in the same package. The shared types.Info should populate B's // definition AND A's reference with the same types.Object. func TestParsePackageResolvesCrossFileSymbols(t *testing.T) { - cus, err := parser.NewGoParser().ParsePackage([]parser.FileInput{ + sfs := parser.NewGoParser().ParsePackage([]parser.FileInput{ {Path: "main.go", Content: "package main\n\nfunc main() { helper() }\n"}, {Path: "helper.go", Content: "package main\n\nfunc helper() {}\n"}, }) - if err != nil { - t.Fatalf("parse error: %v", err) + if len(sfs) != 2 { + t.Fatalf("expected 2 source files, got %d", len(sfs)) } - if len(cus) != 2 { - t.Fatalf("expected 2 CUs, got %d", len(cus)) + cus := make([]*golang.CompilationUnit, 0, len(sfs)) + for _, sf := range sfs { + cu, ok := sf.(*golang.CompilationUnit) + if !ok { + t.Fatalf("expected CompilationUnit, got %T", sf) + } + cus = append(cus, cu) } mainTypes := collectIdentTypes(cus[0]) diff --git a/rewrite-go/test/parse_failure_test.go b/rewrite-go/test/parse_failure_test.go new file mode 100644 index 00000000000..1dbc8754c04 --- /dev/null +++ b/rewrite-go/test/parse_failure_test.go @@ -0,0 +1,103 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test + +import ( + "runtime" + "testing" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" +) + +// TestParsePackageEmitsParseErrorForUnparseableFile verifies that a file +// which fails to parse arrives inline as a *java.ParseError (so the Java +// side represents it as a ParseError rather than a PlainText/Quark) while +// its well-formed siblings still parse to CompilationUnits. +func TestParsePackageEmitsParseErrorForUnparseableFile(t *testing.T) { + sfs := parser.NewGoParser().ParsePackage([]parser.FileInput{ + {Path: "good.go", Content: "package main\n\nfunc main() {}\n"}, + {Path: "bad.go", Content: "package main\n\nfunc broken( {\n"}, + }) + + if len(sfs) != 2 { + t.Fatalf("expected one SourceFile per input, got %d", len(sfs)) + } + + cu, ok := sfs[0].(*golang.CompilationUnit) + if !ok { + t.Fatalf("expected good.go to be a CompilationUnit, got %T", sfs[0]) + } + if cu.SourcePath != "good.go" { + t.Errorf("expected CompilationUnit for good.go, got %q", cu.SourcePath) + } + + pe, ok := sfs[1].(*java.ParseError) + if !ok { + t.Fatalf("expected bad.go to be a ParseError, got %T", sfs[1]) + } + if pe.SourcePath != "bad.go" { + t.Errorf("expected ParseError for bad.go, got %q", pe.SourcePath) + } + if pe.Text != "package main\n\nfunc broken( {\n" { + t.Errorf("ParseError should preserve the original source verbatim, got %q", pe.Text) + } + if pe.Cause() == nil { + t.Error("expected a recoverable cause on the ParseError") + } +} + +// TestParsePackageOmitsBuildExcludedFiles verifies that a file excluded by +// the build context (`//go:build ignore`) is omitted entirely — it is not +// part of this build, so it must not surface as a ParseError (which would +// be a spurious failure) nor as a CompilationUnit. +func TestParsePackageOmitsBuildExcludedFiles(t *testing.T) { + sfs := parser.NewGoParser().ParsePackage([]parser.FileInput{ + {Path: "main.go", Content: "package main\n\nfunc main() {}\n"}, + {Path: "ignored.go", Content: "//go:build ignore\n\npackage main\n"}, + }) + + if len(sfs) != 1 { + t.Fatalf("expected only the build-included file, got %d source files", len(sfs)) + } + if _, ok := sfs[0].(*golang.CompilationUnit); !ok { + t.Fatalf("expected a CompilationUnit, got %T", sfs[0]) + } +} + +// TestParsePackageOmitsGoosExcludedFiles verifies an OS-suffix file for a +// different platform is omitted (not failed), mirroring the build context — +// even when that file would not parse on its own. +func TestParsePackageOmitsGoosExcludedFiles(t *testing.T) { + // Pick an OS suffix that is never the host so the file is always excluded. + otherOS := "windows_amd64" + if runtime.GOOS == "windows" { + otherOS = "linux_amd64" + } + sfs := parser.NewGoParser().ParsePackage([]parser.FileInput{ + {Path: "main.go", Content: "package main\n\nfunc main() {}\n"}, + {Path: "plat_" + otherOS + ".go", Content: "package main\n\nfunc broken( {\n"}, + }) + + if len(sfs) != 1 { + t.Fatalf("expected only the build-included file, got %d source files", len(sfs)) + } + if _, ok := sfs[0].(*golang.CompilationUnit); !ok { + t.Fatalf("expected a CompilationUnit, got %T", sfs[0]) + } +} diff --git a/rewrite-go/test/struct_tag_test.go b/rewrite-go/test/struct_tag_test.go index 44c06b6b7f6..67de381b17b 100644 --- a/rewrite-go/test/struct_tag_test.go +++ b/rewrite-go/test/struct_tag_test.go @@ -187,3 +187,57 @@ func TestStructTag_NonStructDoesNotEmitAnnotations(t *testing.T) { } } } + +// --- Parse/print idempotence for non-canonical tags --- +// +// These reproduce real corpus round-trip failures: a struct tag that can't be +// losslessly reconstructed from the decomposed `key:"value"` annotation form is +// stored verbatim on a StructTag marker and must round-trip byte-for-byte. + +// A struct tag written with double quotes (valid Go, seen in gin's +// binding_test.go) was being DROPPED entirely, because its escaped inner quotes +// don't parse as `key:"value"` pairs. It must now round-trip verbatim. +func TestStructTag_DoubleQuotedRoundtrip(t *testing.T) { + src := "package main\n\ntype T struct {\n\tIdx int \"form:\\\"idx\\\"\"\n}\n" + cu, err := parser.NewGoParser().Parse("test.go", src) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if got := printer.Print(cu); got != src { + t.Errorf("roundtrip mismatch\nexpected: %q\nactual: %q", src, got) + } + vd := parseStructAndFindField(t, src, "Idx") + if java.FindMarker[golang.StructTag](vd.Markers) == nil { + t.Errorf("expected a StructTag marker for the non-canonical (double-quoted) tag") + } + if len(vd.LeadingAnnotations) != 0 { + t.Errorf("non-canonical tag should not be decomposed into annotations, got %d", len(vd.LeadingAnnotations)) + } +} + +// A backtick tag with non-gofmt inner trailing whitespace (seen in caddy's +// acmeserver.go: `json:"challenges,omitempty" `) was being normalized away. +// It must now round-trip verbatim. +func TestStructTag_InnerTrailingWhitespaceRoundtrip(t *testing.T) { + src := "package main\n\ntype T struct {\n\tX int `json:\"x\" `\n}\n" + cu, err := parser.NewGoParser().Parse("test.go", src) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if got := printer.Print(cu); got != src { + t.Errorf("roundtrip mismatch\nexpected: %q\nactual: %q", src, got) + } +} + +// Canonical gofmt'd tags remain decomposed into annotations (no marker), so +// recipes still get structured access to the common case. +func TestStructTag_CanonicalStillUsesAnnotations(t *testing.T) { + src := "package main\n\ntype T struct {\n\tName string `json:\"name\" db:\"name\"`\n}\n" + vd := parseStructAndFindField(t, src, "Name") + if java.FindMarker[golang.StructTag](vd.Markers) != nil { + t.Errorf("canonical tag should not emit a StructTag marker") + } + if got := len(vd.LeadingAnnotations); got != 2 { + t.Errorf("canonical tag should decompose into 2 annotations, got %d", got) + } +} From 5d09d2ee5bd9481a7c358941d3a3884756dd24b6 Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Sat, 20 Jun 2026 01:14:17 -0700 Subject: [PATCH 03/19] Go: write-through module cache + recipe-time graph re-resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes GoModTidy resolve the full transitive module graph by leaning on the standard Go module cache as a persistent, shared backbone. Write-through cache (modgraph/source.go): ProxyWriteThroughSource persists every fetched .mod and .zip (plus a computed h1: .ziphash) into the standard $GOMODCACHE/cache/download//@v/.* layout `go mod download` produces, via atomic temp-file+rename. The first fetch of a module@version costs a network round-trip through the CLI HttpSender; thereafter CacheSource — and the real go toolchain — serve it offline. CacheSource.PackageGoFiles gains a fallback that reads .go files straight from the cached .zip when the extracted tree is absent, so a clean clone needs no `go` extraction step. Unified wiring (cmd/rpc/main.go): moduleSource builds TieredSource(CacheSource, ProxyWriteThroughSource) used by BOTH parse-time graph resolution and the recipe ExecutionContext. Network resolution is on by default; opt out for air-gapped runs with MODERNE_GO_OFFLINE, GOPROXY=off, or MODERNE_GO_PROXY_RESOLVE=0. Recipe-time re-resolution (recipe/golang/go_mod_tidy.go): computeTidySet no longer hard-bails when the parse-time graph was incomplete. When the marker's graph is incomplete it re-resolves now against the network-backed source, so a cold parse no longer pins the recipe to the incomplete LST-only fallback. When resolution genuinely cannot complete (offline + cold cache, private module), it falls back to the LST-only set, which PRESERVES the existing require/// indirect block rather than dropping unconfirmed deps. Net effect on the corpus: direct-dependency parity with `go mod tidy` in every project, and the recipe now prunes spurious indirect entries it previously kept (e.g. caddy reaches exact parity). The remaining test-transitive indirect deps some projects list (e.g. kr/text, go.uber.org/mock) come from go 1.17+'s module-graph pruning-completeness rule (derived from the build list, not package imports) and are a separate, bounded follow-up. --- rewrite-go/cmd/rpc/main.go | 49 ++++- rewrite-go/pkg/parser/modgraph/source.go | 174 ++++++++++++++---- .../pkg/parser/modgraph/writethrough_test.go | 135 ++++++++++++++ rewrite-go/pkg/recipe/golang/go_mod_tidy.go | 40 +++- 4 files changed, 346 insertions(+), 52 deletions(-) create mode 100644 rewrite-go/pkg/parser/modgraph/writethrough_test.go diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index 9fbdf1fc3c3..52a5d0282c6 100644 --- a/rewrite-go/cmd/rpc/main.go +++ b/rewrite-go/cmd/rpc/main.go @@ -884,8 +884,10 @@ func (s *server) fetchHTTP(url string) ([]byte, int, error) { // HttpSender) on a miss. Best-effort: on any failure the marker keeps whatever // was resolved and GraphComplete reflects partiality. // -// Proxy fetching is opt-in via MODERNE_GO_PROXY_RESOLVE to avoid unexpected -// network during parsing; without it, only the local cache is consulted. +// Proxy fetching is on by default (opt out with MODERNE_GO_OFFLINE or +// GOPROXY=off). Fetched modules are written through to the standard module +// cache, so the first parse warms it and every later parse/recipe run — across +// projects on the machine — resolves the full graph offline. func (s *server) resolveModuleGraph(goModContent []byte, mrr *golang.GoResolutionResult) { res, err := modgraph.Resolve(goModContent, s.moduleSource()) if err != nil { @@ -896,18 +898,47 @@ func (s *server) resolveModuleGraph(goModContent []byte, mrr *golang.GoResolutio } // moduleSource builds the dependency-resolution source: the local module cache -// first, then (when MODERNE_GO_PROXY_RESOLVE is set) a GOPROXY tier whose HTTP -// is delegated to the Java HttpSender via the bidirectional Http method. The -// same source is used at parse time and installed into the recipe -// ExecutionContext for recipe-time re-resolution. +// first, then (unless disabled) a write-through GOPROXY tier whose HTTP is +// delegated to the Java HttpSender via the bidirectional Http method. Proxy +// fetches persist .mod/.zip/.ziphash into the standard $GOMODCACHE/cache/download +// layout, so the cache fills in on first use and is shared by every subsequent +// lookup. The same source is used at parse time and installed into the recipe +// ExecutionContext for recipe-time re-resolution, giving both the full graph. func (s *server) moduleSource() modgraph.ModSource { - sources := []modgraph.ModSource{modgraph.CacheSource(envOr("GOMODCACHE", defaultModCache()))} - if os.Getenv("MODERNE_GO_PROXY_RESOLVE") != "" { - sources = append(sources, modgraph.ProxySource(envOr("GOPROXY", "https://proxy.golang.org,direct"), s.fetchHTTP)) + gomodcache := envOr("GOMODCACHE", defaultModCache()) + sources := []modgraph.ModSource{modgraph.CacheSource(gomodcache)} + if proxyResolveEnabled() { + goproxy := envOr("GOPROXY", "https://proxy.golang.org,direct") + sources = append(sources, modgraph.ProxyWriteThroughSource(goproxy, gomodcache, s.fetchHTTP)) } return modgraph.TieredSource(sources...) } +// proxyResolveEnabled reports whether network module resolution is allowed. +// On by default; disabled for air-gapped/offline runs via MODERNE_GO_OFFLINE, +// GOPROXY=off, or an explicit MODERNE_GO_PROXY_RESOLVE=0/false/off. +func proxyResolveEnabled() bool { + if isTruthy(os.Getenv("MODERNE_GO_OFFLINE")) { + return false + } + if strings.TrimSpace(os.Getenv("GOPROXY")) == "off" { + return false + } + if v, ok := os.LookupEnv("MODERNE_GO_PROXY_RESOLVE"); ok && !isTruthy(v) { + return false + } + return true +} + +func isTruthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/rewrite-go/pkg/parser/modgraph/source.go b/rewrite-go/pkg/parser/modgraph/source.go index 2f9ada5f747..88aef529b35 100644 --- a/rewrite-go/pkg/parser/modgraph/source.go +++ b/rewrite-go/pkg/parser/modgraph/source.go @@ -25,6 +25,7 @@ import ( "sync" "golang.org/x/mod/module" + "golang.org/x/mod/sumdb/dirhash" ) // ModSource supplies module metadata (go.mod files, and where available the @@ -93,22 +94,33 @@ func (c *cacheSource) PackageGoFiles(modPath, version, importPath string) (map[s if err != nil { return nil, false } + // Preferred: the extracted module tree (`$GOMODCACHE/@/...`), + // present when `go` has unzipped the module. rel := strings.TrimPrefix(strings.TrimPrefix(importPath, modPath), "/") dir := filepath.Join(c.root, ep+"@"+ev, filepath.FromSlash(rel)) - entries, err := os.ReadDir(dir) - if err != nil { - return nil, false - } - files := map[string][]byte{} - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { - continue + if entries, err := os.ReadDir(dir); err == nil { + files := map[string][]byte{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { + continue + } + if b, err := os.ReadFile(filepath.Join(dir, e.Name())); err == nil { + files[e.Name()] = b + } } - if b, err := os.ReadFile(filepath.Join(dir, e.Name())); err == nil { - files[e.Name()] = b + if len(files) > 0 { + return files, true } } - return files, len(files) > 0 + // Fallback: the cached download zip (`cache/download//@v/.zip`), + // which the write-through proxy persists even when `go` has never run, so a + // clean clone can serve dependency sources without any extraction step. + if raw, err := os.ReadFile(filepath.Join(c.download, ep, "@v", ev+".zip")); err == nil { + if entries, err := goFilesFromZip(raw); err == nil { + return packageFilesFromZipEntries(entries, modPath, version, importPath) + } + } + return nil, false } // ProxySource fetches module metadata from one or more GOPROXY base URLs using @@ -117,6 +129,26 @@ func (c *cacheSource) PackageGoFiles(modPath, version, importPath string) (map[s // (this source only speaks the proxy protocol). If goproxy is empty, // https://proxy.golang.org is used. func ProxySource(goproxy string, get HTTPGet) ModSource { + return newProxySource(goproxy, get, "") +} + +// ProxyWriteThroughSource is like ProxySource but additionally persists every +// successfully fetched .mod and .zip (plus the computed .ziphash) into the +// standard Go module download cache at gomodcache/cache/download, using the +// exact on-disk layout `go mod download` produces. The first fetch of a +// module@version costs a network round-trip; thereafter CacheSource (and the +// real `go` toolchain) serve it offline. Writes are atomic (temp-file+rename), +// so concurrent readers never observe a partial file. If gomodcache is empty it +// behaves exactly like ProxySource (no persistence). +func ProxyWriteThroughSource(goproxy, gomodcache string, get HTTPGet) ModSource { + cacheDir := "" + if gomodcache != "" { + cacheDir = filepath.Join(gomodcache, "cache", "download") + } + return newProxySource(goproxy, get, cacheDir) +} + +func newProxySource(goproxy string, get HTTPGet, cacheDir string) *proxySource { var bases []string for _, p := range strings.FieldsFunc(goproxy, func(r rune) bool { return r == ',' || r == '|' }) { p = strings.TrimSpace(p) @@ -128,13 +160,16 @@ func ProxySource(goproxy string, get HTTPGet) ModSource { if len(bases) == 0 { bases = []string{"https://proxy.golang.org"} } - return &proxySource{bases: bases, get: get, zips: map[string]map[string][]byte{}} + return &proxySource{bases: bases, get: get, cacheDir: cacheDir, zips: map[string]map[string][]byte{}} } type proxySource struct { bases []string get HTTPGet - mu sync.Mutex + // cacheDir, when non-empty, is GOMODCACHE/cache/download: fetched .mod/.zip + // are written through to it in the standard layout. + cacheDir string + mu sync.Mutex // zips caches the extracted contents of a module zip, keyed by // "modPath@version" -> (full zip entry path -> bytes). A nil value records // a failed download so it is not retried. @@ -154,6 +189,7 @@ func (p *proxySource) GoMod(path, version string) ([]byte, bool) { for _, base := range p.bases { body, status, err := p.get(base + suffix) if err == nil && status == 200 && len(body) > 0 { + p.persist(ep, ev, ".mod", body) return body, true } } @@ -171,25 +207,7 @@ func (p *proxySource) PackageGoFiles(modPath, version, importPath string) (map[s if !ok { return nil, false } - rel := strings.TrimPrefix(strings.TrimPrefix(importPath, modPath), "/") - // Package files live directly under "@//" — the - // package is exactly one directory level (no deeper recursion). - prefix := modPath + "@" + version + "/" - if rel != "" { - prefix += rel + "/" - } - files := map[string][]byte{} - for name, content := range entries { - if !strings.HasPrefix(name, prefix) { - continue - } - tail := name[len(prefix):] - if strings.Contains(tail, "/") || !strings.HasSuffix(tail, ".go") { - continue // a deeper subpackage or non-go file - } - files[tail] = content - } - return files, len(files) > 0 + return packageFilesFromZipEntries(entries, modPath, version, importPath) } // moduleZip downloads (once) and extracts the module zip for modPath@version, @@ -231,10 +249,48 @@ func (p *proxySource) downloadZip(modPath, version string) map[string][]byte { if raw == nil { return nil } - zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + entries, err := goFilesFromZip(raw) if err != nil { return nil } + // Write through to the standard cache only after a successful parse, so we + // never persist a corrupt/truncated download. + p.persistZip(ep, ev, raw) + return entries +} + +// persist atomically writes content to GOMODCACHE/cache/download//@v/ +// when a write-through cache dir is configured. Best-effort: cache write +// failures never affect resolution (the bytes are already in hand). +func (p *proxySource) persist(ep, ev, suffix string, content []byte) { + if p.cacheDir == "" { + return + } + _ = atomicWriteFile(filepath.Join(p.cacheDir, ep, "@v", ev+suffix), content) +} + +// persistZip writes the raw module zip and its computed h1: .ziphash (the value +// `go` records in go.sum and the cache .ziphash file) into the standard cache. +func (p *proxySource) persistZip(ep, ev string, raw []byte) { + if p.cacheDir == "" { + return + } + zipPath := filepath.Join(p.cacheDir, ep, "@v", ev+".zip") + if err := atomicWriteFile(zipPath, raw); err != nil { + return + } + if h, err := dirhash.HashZip(zipPath, dirhash.Hash1); err == nil { + _ = atomicWriteFile(filepath.Join(p.cacheDir, ep, "@v", ev+".ziphash"), []byte(h)) + } +} + +// goFilesFromZip extracts every .go file from a module zip, keyed by its full +// "@/" entry name. +func goFilesFromZip(raw []byte) (map[string][]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + return nil, err + } entries := map[string][]byte{} for _, f := range zr.File { if f.FileInfo().IsDir() || !strings.HasSuffix(f.Name, ".go") { @@ -249,7 +305,57 @@ func (p *proxySource) downloadZip(modPath, version string) map[string][]byte { rc.Close() entries[f.Name] = buf.Bytes() } - return entries + return entries, nil +} + +// packageFilesFromZipEntries filters extracted zip entries down to the .go files +// of exactly one package directory (importPath within modPath@version). +func packageFilesFromZipEntries(entries map[string][]byte, modPath, version, importPath string) (map[string][]byte, bool) { + rel := strings.TrimPrefix(strings.TrimPrefix(importPath, modPath), "/") + prefix := modPath + "@" + version + "/" + if rel != "" { + prefix += rel + "/" + } + files := map[string][]byte{} + for name, content := range entries { + if !strings.HasPrefix(name, prefix) { + continue + } + tail := name[len(prefix):] + if strings.Contains(tail, "/") || !strings.HasSuffix(tail, ".go") { + continue // a deeper subpackage or non-go file + } + files[tail] = content + } + return files, len(files) > 0 +} + +// atomicWriteFile writes data to path via a temp file + rename, creating parent +// dirs as needed, so a concurrent reader never observes a partial file. +func atomicWriteFile(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return err + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return err + } + return nil } // TieredSource tries each source in order, returning the first hit. Use it to diff --git a/rewrite-go/pkg/parser/modgraph/writethrough_test.go b/rewrite-go/pkg/parser/modgraph/writethrough_test.go new file mode 100644 index 00000000000..674444de10e --- /dev/null +++ b/rewrite-go/pkg/parser/modgraph/writethrough_test.go @@ -0,0 +1,135 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package modgraph + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeZip builds a minimal but valid module zip: every entry is prefixed +// "@/" as the module proxy requires. +func fakeZip(t *testing.T, modPath, version string, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, content := range files { + w, err := zw.Create(modPath + "@" + version + "/" + name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// TestWriteThroughPersistsStandardCacheLayout verifies that fetching a module's +// .mod and .zip through ProxyWriteThroughSource persists them (plus a computed +// h1: .ziphash) into the standard $GOMODCACHE/cache/download layout, and that a +// fresh CacheSource pointed at that directory then serves the same data offline +// — including package .go files via the zip fallback (no extraction needed). +func TestWriteThroughPersistsStandardCacheLayout(t *testing.T) { + const modPath = "example.com/dep" + const version = "v1.2.3" + goMod := []byte("module example.com/dep\n\ngo 1.21\n") + zipBytes := fakeZip(t, modPath, version, map[string]string{ + "pkg/util.go": "package util\n\nimport _ \"example.com/other\"\n", + "go.mod": string(goMod), + }) + + gomodcache := t.TempDir() + var fetched []string + get := func(url string) ([]byte, int, error) { + fetched = append(fetched, url) + switch { + case strings.HasSuffix(url, "/@v/"+version+".mod"): + return goMod, 200, nil + case strings.HasSuffix(url, "/@v/"+version+".zip"): + return zipBytes, 200, nil + } + return nil, 404, nil + } + + src := ProxyWriteThroughSource("https://proxy.example", gomodcache, get) + + // 1. Fetch .mod and package files through the proxy (warming the cache). + if b, ok := src.GoMod(modPath, version); !ok || !bytes.Equal(b, goMod) { + t.Fatalf("proxy GoMod: ok=%v bytes=%q", ok, b) + } + files, ok := src.PackageGoFiles(modPath, version, modPath+"/pkg") + if !ok || len(files) != 1 || !strings.Contains(string(files["util.go"]), "package util") { + t.Fatalf("proxy PackageGoFiles: ok=%v files=%v", ok, files) + } + + // 2. The standard cache layout must now hold .mod, .zip, and .ziphash. + dl := filepath.Join(gomodcache, "cache", "download", modPath, "@v") + for _, suffix := range []string{".mod", ".zip", ".ziphash"} { + if _, err := os.Stat(filepath.Join(dl, version+suffix)); err != nil { + t.Errorf("expected cached %s in standard layout: %v", version+suffix, err) + } + } + zh, err := os.ReadFile(filepath.Join(dl, version+".ziphash")) + if err != nil || !strings.HasPrefix(string(zh), "h1:") { + t.Errorf("ziphash not a valid h1: hash: %q (err=%v)", zh, err) + } + + // 3. A fresh CacheSource (no proxy) must serve everything offline. + cache := CacheSource(gomodcache) + if b, ok := cache.GoMod(modPath, version); !ok || !bytes.Equal(b, goMod) { + t.Errorf("cache GoMod after write-through: ok=%v", ok) + } + if h, ok := cache.ZipHash(modPath, version); !ok || h != strings.TrimSpace(string(zh)) { + t.Errorf("cache ZipHash after write-through: ok=%v h=%q want %q", ok, h, zh) + } + cfiles, ok := cache.PackageGoFiles(modPath, version, modPath+"/pkg") + if !ok || !strings.Contains(string(cfiles["util.go"]), `import _ "example.com/other"`) { + t.Errorf("cache PackageGoFiles via zip fallback: ok=%v files=%v", ok, cfiles) + } +} + +// TestProxySourceWithoutCacheDoesNotPersist guards the opt-in: plain ProxySource +// (no gomodcache) must not write anything to disk. +func TestProxySourceWithoutCacheDoesNotPersist(t *testing.T) { + const modPath = "example.com/dep" + const version = "v0.1.0" + goMod := []byte("module example.com/dep\n\ngo 1.21\n") + gomodcache := t.TempDir() + get := func(url string) ([]byte, int, error) { + if strings.HasSuffix(url, ".mod") { + return goMod, 200, nil + } + return nil, 404, nil + } + // Plain ProxySource ignores the cache dir entirely. + src := ProxySource("https://proxy.example", get) + if _, ok := src.GoMod(modPath, version); !ok { + t.Fatal("GoMod should succeed") + } + dl := filepath.Join(gomodcache, "cache", "download") + if entries, _ := os.ReadDir(dl); len(entries) != 0 { + t.Errorf("plain ProxySource must not persist; found %d entries", len(entries)) + } +} diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go index 9eb518a111c..c233997dd22 100644 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go @@ -25,6 +25,7 @@ import ( "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" + "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe/golang/internal" "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" @@ -401,15 +402,22 @@ func headerInsertIndex(stmts []java.RightPadded[golang.GoModStatement]) int { } // computeTidySet computes the exact go.mod require set at recipe time from the -// parse-time-resolved module graph (carried on the marker), the imports scanned -// from the project's .go files, and the dependency ModSource installed in the -// ExecutionContext (cache + GOPROXY via the CLI HttpSender). Returns ok=false -// when any input is missing, so the caller falls back to the marker's -// authoritative set (tests) or LST-only Phase 1. +// resolved module graph, the imports scanned from the project's .go files, and +// the dependency ModSource installed in the ExecutionContext (cache + write- +// through GOPROXY via the CLI HttpSender). +// +// The graph is taken from the parse-time marker when it is complete; otherwise +// it is re-resolved NOW against the same source. This matters because parse-time +// resolution may be cold (cache-only / offline) while the recipe — which is +// explicitly editing dependencies — is allowed to reach the network and warm +// the shared cache. Without this, a cold parse would permanently pin the recipe +// to the incomplete LST-only fallback even with the network available. +// +// Returns ok=false when the source is absent or resolution still cannot complete +// (truly offline + cold cache, or a private module), so the caller falls back to +// the marker's authoritative set (tests) or the LST-only set, which PRESERVES +// the existing require/// indirect block rather than dropping unconfirmed deps. func (v *goModTidyEditor) computeTidySet(gm *golang.GoMod, res *golang.GoResolutionResult, separateIndirect bool, p any) ([]reqEntry, bool) { - if res == nil || !res.GraphComplete || len(res.BuildList) == 0 { - return nil, false - } ctx, ok := p.(*recipe.ExecutionContext) if !ok { return nil, false @@ -419,11 +427,25 @@ func (v *goModTidyEditor) computeTidySet(gm *golang.GoMod, res *golang.GoResolut return nil, false } + // Obtain a module graph to walk: prefer the parse-time graph when complete, + // else re-resolve now against the (network-backed, write-through) source. + var graph modgraph.Result + if res != nil && res.GraphComplete && len(res.BuildList) > 0 { + graph = modgraph.FromMarker(*res) + } else { + content := printer.PrintGoMod(gm) + r, err := modgraph.Resolve([]byte(content), src) + if err != nil || !r.Complete || len(r.BuildList) == 0 { + return nil, false + } + graph = r + } + mainImports := make([]string, 0, len(v.acc.rawImports)) for imp := range v.acc.rawImports { mainImports = append(mainImports, imp) } - rs := modgraph.NeededModules(mainImports, v.acc.modulePath, modgraph.FromMarker(*res), src, separateIndirect) + rs := modgraph.NeededModules(mainImports, v.acc.modulePath, graph, src, separateIndirect) if !rs.Complete { return nil, false } From 8f125d878ebc9c33b31c55a4e37949b24477a929 Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Sat, 20 Jun 2026 01:36:19 -0700 Subject: [PATCH 04/19] Go: add go1.17+ pruning-completeness roots to GoModTidy (exact parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NeededModules computes the modules that PROVIDE a package in `all` (direct + import-reachable indirect). For a go>=1.17 main module, `go mod tidy` also records indirect roots for test-transitive dependencies that the pruned module graph would under-select — e.g. gin's kr/text (via gopkg.in/check.v1) and go.uber.org/mock (via a dependency's test), cli's gotest.tools/v3 and jedisct1/go-minisign. NeededModules misses these because they are not reachable by walking ordinary package imports. TidyRequireSet (modgraph/tidy.go) adds them, mirroring cmd/go/internal/modload.tidyPrunedRoots: start from the import-reachable roots, walk imports AND tests outward from `all`, and promote a module to an explicit root whenever the pruned graph under the current roots selects a lower version than the one actually loaded (Selected(path) < loadedVersion). The version gate is essential — it adds genuinely under-selected modules while leaving testify- style clusters out when the pruned graph already selects them correctly, so it does not over-include. The pruned selection under a candidate root set is computed by building a synthetic go.mod (preserving the main module's go directive and replaces) that requires exactly those roots and re-resolving; go.mod fetches are served from the write-through cache. Iterates to a fixpoint. No-ops for go<1.17. Validated: new golden test TidyRequireSet-via-proxy (no-extras and testify cases) matches `go mod tidy`; live CLI runs bring gin and cli to exact parity (0 missing, 0 extra), with cobra/testify/mux/uuid unchanged. All 12 corpus projects now match `go mod tidy` exactly. --- rewrite-go/pkg/parser/modgraph/needed.go | 36 ++++ rewrite-go/pkg/parser/modgraph/tidy.go | 217 ++++++++++++++++++++ rewrite-go/pkg/parser/modgraph/tidy_test.go | 103 ++++++++++ rewrite-go/pkg/recipe/golang/go_mod_tidy.go | 8 +- 4 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 rewrite-go/pkg/parser/modgraph/tidy.go create mode 100644 rewrite-go/pkg/parser/modgraph/tidy_test.go diff --git a/rewrite-go/pkg/parser/modgraph/needed.go b/rewrite-go/pkg/parser/modgraph/needed.go index ff38c81aa1b..af4cfef7a37 100644 --- a/rewrite-go/pkg/parser/modgraph/needed.go +++ b/rewrite-go/pkg/parser/modgraph/needed.go @@ -199,6 +199,42 @@ func packageImports(src ModSource, mod, version, importPath string) ([]string, e return out, nil } +// packageImportsWithTests is like packageImports but returns the package's +// ordinary imports and its test-file imports separately. Used by the pruning- +// completeness pass, which must follow test imports of dependency packages. +func packageImportsWithTests(src ModSource, mod, version, importPath string) (imports, testImports []string, err error) { + files, ok := src.PackageGoFiles(mod, version, importPath) + if !ok { + return nil, nil, errPackageNotFound + } + impSet, testSet := map[string]bool{}, map[string]bool{} + fset := token.NewFileSet() + for name, content := range files { + f, perr := goparser.ParseFile(fset, name, content, goparser.ImportsOnly) + if perr != nil { + continue + } + target := impSet + if strings.HasSuffix(name, "_test.go") { + target = testSet + } + for _, spec := range f.Imports { + p := strings.Trim(spec.Path.Value, "\"`") + if p != "" { + target[p] = true + } + } + } + keys := func(set map[string]bool) []string { + out := make([]string, 0, len(set)) + for p := range set { + out = append(out, p) + } + return out + } + return keys(impSet), keys(testSet), nil +} + // isStdlibImport reports whether importPath is a standard-library package // (no dot in its first path segment). Mirrors gofmt/goimports' heuristic. func isStdlibImport(importPath string) bool { diff --git a/rewrite-go/pkg/parser/modgraph/tidy.go b/rewrite-go/pkg/parser/modgraph/tidy.go new file mode 100644 index 00000000000..01debffbf3c --- /dev/null +++ b/rewrite-go/pkg/parser/modgraph/tidy.go @@ -0,0 +1,217 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package modgraph + +import ( + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/semver" +) + +// TidyRequireSet computes the exact go.mod require set `go mod tidy` would write, +// including the go 1.17+ pruning-completeness roots that NeededModules alone does +// not capture. +// +// NeededModules gives the modules that PROVIDE a package in `all` (direct + +// import-reachable indirect). For a go>=1.17 main module, `go mod tidy` also adds +// an indirect root for every module that is reachable from `all` through imports +// OR TESTS but whose version the pruned module graph would UNDER-SELECT — i.e. a +// test-transitive dependency whose required version is higher than any root's +// pruned go.mod implies (e.g. kr/text via gopkg.in/check.v1, go.uber.org/mock via +// a dependency's test). This mirrors cmd/go/internal/modload.tidyPrunedRoots: +// walk imports+tests outward from `all`, and promote a module to an explicit root +// whenever Selected(path) < loadedVersion(path). +// +// The version gate is essential: it adds mock/kr/text (genuinely under-selected) +// while leaving testify-style clusters out when the pruned graph already selects +// them correctly. For go<1.17 (no pruning) or an incomplete base resolution it +// returns NeededModules unchanged. +func TidyRequireSet(mainImports []string, mainModulePath, mainGoMod string, res Result, src ModSource, separateIndirect bool) RequireSet { + base := NeededModules(mainImports, mainModulePath, res, src, separateIndirect) + if !separateIndirect || !base.Complete { + return base + } + + // loaded[path] = the version each module is selected at in the full build + // list (go's `m.Version` — the version a package's module is loaded at). + loaded := map[string]string{} + for _, m := range res.BuildList { + if !m.Main { + loaded[m.Path] = m.Version + } + } + + // Reachable modules via imports AND tests, starting from `all`. Their + // providing modules are the candidates that may need explicit roots. + reachable := testReachableModules(mainImports, mainModulePath, res, src) + + // Current root set: everything NeededModules already requires. + roots := map[string]string{} + for p, v := range base.Direct { + roots[p] = v + } + for p, v := range base.Indirect { + roots[p] = v + } + + // Iterate: under the current roots' PRUNED graph, promote any reachable + // module that is under-selected to a root. Adding roots raises selections, + // so repeat to a fixpoint (bounded by the number of candidates). + for { + sel, ok := prunedSelection(mainGoMod, roots, loaded, src) + if !ok { + return base // re-resolution failed; fall back rather than guess + } + added := false + for path := range reachable { + if _, isRoot := roots[path]; isRoot { + continue + } + want, have := loaded[path], sel[path] + if want == "" { + continue + } + if have == "" || semver.Compare(have, want) < 0 { + roots[path] = want + added = true + } + } + if !added { + break + } + } + + // Reclassify: direct stays direct; every other root is indirect. + out := RequireSet{ + Direct: map[string]string{}, + Indirect: map[string]string{}, + Complete: true, + Unresolved: base.Unresolved, + MissingDirs: base.MissingDirs, + } + for p, v := range roots { + if _, isDirect := base.Direct[p]; isDirect { + out.Direct[p] = v + } else { + out.Indirect[p] = v + } + } + return out +} + +// prunedSelection returns the MVS-selected version of every module under the +// go>=1.17 PRUNED module graph rooted at the given root set. It builds a +// synthetic go.mod (preserving the main module's go directive, replaces, and +// excludes) requiring each root at its loaded version, then re-resolves. +func prunedSelection(mainGoMod string, roots, loaded map[string]string, src ModSource) (map[string]string, bool) { + mf, err := modfile.Parse("go.mod", []byte(mainGoMod), nil) + if err != nil { + return nil, false + } + // Replace the require block with exactly our root set. + for _, r := range mf.Require { + _ = mf.DropRequire(r.Mod.Path) + } + for path := range roots { + v := loaded[path] + if v == "" { + v = roots[path] + } + if v == "" { + continue + } + _ = mf.AddRequire(path, v) + } + mf.Cleanup() + synthetic := modfile.Format(mf.Syntax) + + res, err := Resolve(synthetic, src) + if err != nil || !res.Complete { + return nil, false + } + sel := map[string]string{} + for _, m := range res.BuildList { + if !m.Main { + sel[m.Path] = m.Version + } + } + return sel, true +} + +// testReachableModules returns the set of module paths reachable from the main +// module's imports by following BOTH ordinary and test imports, recursively. It +// maps each reached package to its providing build-list module. This is the set +// of modules `go mod tidy` considers when deciding which need explicit roots for +// the pruned graph to be reproducible. +func testReachableModules(mainImports []string, mainModulePath string, res Result, src ModSource) map[string]bool { + type modver struct{ path, version string } + var mods []modver + for _, m := range res.BuildList { + if !m.Main { + mods = append(mods, modver{m.Path, m.Version}) + } + } + moduleOf := func(importPath string) (string, string) { + best, bestVer := "", "" + for _, m := range mods { + if importPath == m.path || strings.HasPrefix(importPath, m.path+"/") { + if len(m.path) > len(best) { + best, bestVer = m.path, m.version + } + } + } + return best, bestVer + } + isLocal := func(importPath string) bool { + return importPath == mainModulePath || strings.HasPrefix(importPath, mainModulePath+"/") + } + + out := map[string]bool{} + visited := map[string]bool{} + var queue []string + queue = append(queue, mainImports...) + + for len(queue) > 0 { + imp := queue[0] + queue = queue[1:] + if visited[imp] || isStdlibImport(imp) || isLocal(imp) { + continue + } + visited[imp] = true + m, ver := moduleOf(imp) + if m == "" { + continue + } + out[m] = true + imports, testImports, err := packageImportsWithTests(src, m, ver, imp) + if err != nil { + continue + } + for _, dep := range imports { + if !visited[dep] { + queue = append(queue, dep) + } + } + for _, dep := range testImports { + if !visited[dep] { + queue = append(queue, dep) + } + } + } + return out +} diff --git a/rewrite-go/pkg/parser/modgraph/tidy_test.go b/rewrite-go/pkg/parser/modgraph/tidy_test.go new file mode 100644 index 00000000000..ef7075d576b --- /dev/null +++ b/rewrite-go/pkg/parser/modgraph/tidy_test.go @@ -0,0 +1,103 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package modgraph + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestTidyRequireSetViaProxy validates the full TidyRequireSet path (NeededModules +// + go>=1.17 pruning-completeness roots) against real `go mod tidy`, fetching +// everything from the proxy. Each case asserts the computed direct/indirect set +// EXACTLY matches go.mod — proving the pruning pass adds genuinely-needed +// test-transitive roots without over-including the test clusters that the version +// gate must exclude. +func TestTidyRequireSetViaProxy(t *testing.T) { + if testing.Short() { + t.Skip("needs network + the go toolchain") + } + cases := []struct { + name, modPath, goMod, mainGo string + }{ + { + // No pruning-completeness extras: indirect == import-reachable only. + // Guards against over-inclusion through the full Tidy path. + name: "no_extras", + modPath: "example.com/noextras", + goMod: "module example.com/noextras\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n", + mainGo: "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n", + }, + { + // testify pulls yaml.v3, whose TEST imports gopkg.in/check.v1 -> + // kr/text: a classic pruning-completeness indirect that import-only + // resolution misses but `go mod tidy` records. + name: "test_transitive", + modPath: "example.com/testtrans", + goMod: "module example.com/testtrans\n\ngo 1.25.0\n\nrequire github.com/stretchr/testify v1.9.0\n", + mainGo: "package main\n\nimport _ \"github.com/stretchr/testify/assert\"\n\nfunc main() {}\n", + }, + } + + httpGet := func(url string) ([]byte, int, error) { + resp, err := http.Get(url) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return b, resp.StatusCode, nil + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + write(t, dir, "go.mod", tc.goMod) + write(t, dir, "main.go", tc.mainGo) + runGo(t, dir, "mod", "tidy") + wantDirect, wantIndirect := goldenRequires(t, dir) + mainImports := scanMainImports(t, dir) + + tidied, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + t.Fatal(err) + } + src := ProxySource(strings.TrimSpace(runGo(t, dir, "env", "GOPROXY")), httpGet) + res, err := Resolve(tidied, src) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + rs := TidyRequireSet(mainImports, tc.modPath, string(tidied), res, src, true) + if !rs.Complete { + t.Fatalf("expected complete TidyRequireSet; got Complete=false") + } + if d := diffSet(wantDirect, keys(rs.Direct)); d != "" { + t.Errorf("direct mismatch:\n%s", d) + } + if d := diffSet(wantIndirect, keys(rs.Indirect)); d != "" { + t.Errorf("indirect mismatch:\n%s", d) + } + if !t.Failed() { + t.Logf("OK: TidyRequireSet indirect=%v matches go mod tidy", keys(rs.Indirect)) + } + }) + } +} diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go index c233997dd22..acddcb0a4b8 100644 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go @@ -429,11 +429,11 @@ func (v *goModTidyEditor) computeTidySet(gm *golang.GoMod, res *golang.GoResolut // Obtain a module graph to walk: prefer the parse-time graph when complete, // else re-resolve now against the (network-backed, write-through) source. + content := printer.PrintGoMod(gm) var graph modgraph.Result if res != nil && res.GraphComplete && len(res.BuildList) > 0 { graph = modgraph.FromMarker(*res) } else { - content := printer.PrintGoMod(gm) r, err := modgraph.Resolve([]byte(content), src) if err != nil || !r.Complete || len(r.BuildList) == 0 { return nil, false @@ -445,7 +445,11 @@ func (v *goModTidyEditor) computeTidySet(gm *golang.GoMod, res *golang.GoResolut for imp := range v.acc.rawImports { mainImports = append(mainImports, imp) } - rs := modgraph.NeededModules(mainImports, v.acc.modulePath, graph, src, separateIndirect) + // TidyRequireSet = NeededModules (import-reachable) + the go>=1.17 pruning- + // completeness roots (test-transitive indirect deps under-selected by the + // pruned graph). The latter requires the go.mod text for synthetic re- + // resolution; it no-ops for go<1.17. + rs := modgraph.TidyRequireSet(mainImports, v.acc.modulePath, content, graph, src, separateIndirect) if !rs.Complete { return nil, false } From 8a2f2dbc5c937dba1b9d65384ad80d372d060bf0 Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Sat, 20 Jun 2026 01:48:55 -0700 Subject: [PATCH 05/19] Go: compute pruned selection in memory, dropping per-iteration re-resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pruning-completeness pass decided whether a candidate module was under- selected by re-resolving a synthetic go.mod (Resolve) on every fixpoint iteration — re-reading and re-parsing every dependency go.mod each time. Replace that with an in-memory pruned MVS over a requirement index. The index is seeded for free from the already-resolved graph (res.Graph carries every LOADED module's require edges) and lazily fetches the go.mod only for the few pruned modules that pruning left unloaded AND that get promoted to roots — typically a handful of cache reads total, versus O(deps) parses per iteration. prunedSelectInMemory mirrors Resolve's traversal exactly (a module's requires are recursed only when it is unpruned, i.e. go<1.17), so results are identical; main-module version replacements are carried into the index. Validated: golden TidyRequireSet-via-proxy cases (no-extras, testify, gin app) still match `go mod tidy`; live CLI keeps exact parity on gin and cli (the promotion cases) and cobra (clean), with no over/under-inclusion. --- rewrite-go/pkg/parser/modgraph/tidy.go | 166 ++++++++++++++++---- rewrite-go/pkg/parser/modgraph/tidy_test.go | 10 ++ 2 files changed, 145 insertions(+), 31 deletions(-) diff --git a/rewrite-go/pkg/parser/modgraph/tidy.go b/rewrite-go/pkg/parser/modgraph/tidy.go index 01debffbf3c..9081f3f7c38 100644 --- a/rewrite-go/pkg/parser/modgraph/tidy.go +++ b/rewrite-go/pkg/parser/modgraph/tidy.go @@ -20,6 +20,7 @@ import ( "strings" "golang.org/x/mod/modfile" + "golang.org/x/mod/module" "golang.org/x/mod/semver" ) @@ -69,14 +70,17 @@ func TidyRequireSet(mainImports []string, mainModulePath, mainGoMod string, res roots[p] = v } + // Build a requirement index once: every module version's direct requires, + // seeded for free from the already-resolved graph and lazily fetching only + // the few promoted roots that pruning left unloaded. Pruned MVS then runs + // entirely in memory — no per-iteration re-resolution. + idx := newReqIndex(res, mainGoMod, src) + // Iterate: under the current roots' PRUNED graph, promote any reachable // module that is under-selected to a root. Adding roots raises selections, // so repeat to a fixpoint (bounded by the number of candidates). for { - sel, ok := prunedSelection(mainGoMod, roots, loaded, src) - if !ok { - return base // re-resolution failed; fall back rather than guess - } + sel := prunedSelectInMemory(roots, idx) added := false for path := range reachable { if _, isRoot := roots[path]; isRoot { @@ -114,43 +118,143 @@ func TidyRequireSet(mainImports []string, mainModulePath, mainGoMod string, res return out } -// prunedSelection returns the MVS-selected version of every module under the -// go>=1.17 PRUNED module graph rooted at the given root set. It builds a -// synthetic go.mod (preserving the main module's go directive, replaces, and -// excludes) requiring each root at its loaded version, then re-resolves. -func prunedSelection(mainGoMod string, roots, loaded map[string]string, src ModSource) (map[string]string, bool) { - mf, err := modfile.Parse("go.mod", []byte(mainGoMod), nil) +// reqIndex memoizes each module version's direct requirements (post-replace) and +// go directive. It is seeded for free from an already-resolved graph (whose edges +// cover every LOADED module) and lazily fetches the go.mod of any module that +// pruning left unloaded — only ever the handful of pruned modules promoted to +// roots. This lets prunedSelectInMemory run a pruned MVS without re-resolving. +type reqIndex struct { + edges map[string][]module.Version // "path@version" -> requires (post-replace) + gover map[string]string // "path@version" -> go directive + known map[string]bool // keys whose edges are fully populated + replace map[string]module.Version // main module's version replacements + src ModSource +} + +func newReqIndex(res Result, mainGoMod string, src ModSource) *reqIndex { + idx := &reqIndex{ + edges: map[string][]module.Version{}, + gover: map[string]string{}, + known: map[string]bool{}, + replace: map[string]module.Version{}, + src: src, + } + // Main module's version replacements (local-path replaces are skipped; they + // already mark the resolution incomplete upstream). + if mf, err := modfile.Parse("go.mod", []byte(mainGoMod), nil); err == nil { + for _, r := range mf.Replace { + if r.New.Version == "" { + continue + } + idx.replace[r.Old.Path] = r.New + idx.replace[r.Old.Path+"@"+r.Old.Version] = r.New + } + } + // Seed edges from the resolved graph. Every module that was LOADED contributes + // all of its require edges here, so its key is fully known. + for _, e := range res.Graph { + key := e.FromPath + "@" + e.FromVersion + idx.edges[key] = append(idx.edges[key], module.Version{Path: e.ToPath, Version: e.ToVersion}) + idx.known[key] = true + } + for _, m := range res.BuildList { + idx.gover[m.Path+"@"+m.Version] = m.GoVersion + } + return idx +} + +func (idx *reqIndex) applyReplace(m module.Version) module.Version { + if nv, ok := idx.replace[m.Path+"@"+m.Version]; ok { + return nv + } + if nv, ok := idx.replace[m.Path]; ok { + return nv + } + return m +} + +// requires returns module path@version's direct requirements and go directive, +// fetching and memoizing the go.mod when not already seeded. +func (idx *reqIndex) requires(path, version string) ([]module.Version, string) { + key := path + "@" + version + if idx.known[key] { + return idx.edges[key], idx.gover[key] + } + idx.known[key] = true // memoize even on miss, so a failed fetch isn't retried + b, ok := idx.src.GoMod(path, version) + if !ok { + return nil, idx.gover[key] + } + df, err := modfile.Parse(key, b, nil) if err != nil { - return nil, false + return nil, idx.gover[key] + } + if df.Go != nil { + idx.gover[key] = df.Go.Version } - // Replace the require block with exactly our root set. - for _, r := range mf.Require { - _ = mf.DropRequire(r.Mod.Path) + reqs := make([]module.Version, 0, len(df.Require)) + for _, r := range df.Require { + reqs = append(reqs, idx.applyReplace(r.Mod)) } - for path := range roots { - v := loaded[path] - if v == "" { - v = roots[path] + idx.edges[key] = reqs + return reqs, idx.gover[key] +} + +// prunedSelectInMemory computes the MVS-selected version of every module under the +// go>=1.17 PRUNED module graph rooted at the given root set, reading requirements +// from idx. It mirrors Resolve's traversal exactly: every loaded module's requires +// become build-list nodes, but a module's requires are recursed into only when the +// module is unpruned (its go directive is < 1.17). +func prunedSelectInMemory(roots map[string]string, idx *reqIndex) map[string]string { + present := map[string]string{} // path -> selected version + loadPath := map[string]bool{} // paths we recurse into + enqueued := map[string]bool{} + type pv struct{ path, version string } + var queue []pv + + enqueue := func(m module.Version) { + k := m.Path + "@" + m.Version + if !enqueued[k] { + enqueued[k] = true + queue = append(queue, pv{m.Path, m.Version}) } - if v == "" { - continue + } + setNode := func(m module.Version) { + if v, ok := present[m.Path]; !ok || semver.Compare(m.Version, v) > 0 { + present[m.Path] = m.Version + if loadPath[m.Path] { + enqueue(m) + } } - _ = mf.AddRequire(path, v) } - mf.Cleanup() - synthetic := modfile.Format(mf.Syntax) + markLoad := func(m module.Version) { + loadPath[m.Path] = true + enqueue(m) + } - res, err := Resolve(synthetic, src) - if err != nil || !res.Complete { - return nil, false + // Roots = the synthetic main module's requirements. + for path, ver := range roots { + m := module.Version{Path: path, Version: ver} + setNode(m) + markLoad(m) } - sel := map[string]string{} - for _, m := range res.BuildList { - if !m.Main { - sel[m.Path] = m.Version + + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + if present[cur.path] != cur.version { + continue // superseded by a higher selected version + } + reqs, goV := idx.requires(cur.path, cur.version) + unpruned := goUnpruned(goV) + for _, req := range reqs { + setNode(req) + if unpruned { + markLoad(req) + } } } - return sel, true + return present } // testReachableModules returns the set of module paths reachable from the main diff --git a/rewrite-go/pkg/parser/modgraph/tidy_test.go b/rewrite-go/pkg/parser/modgraph/tidy_test.go index ef7075d576b..9921340985c 100644 --- a/rewrite-go/pkg/parser/modgraph/tidy_test.go +++ b/rewrite-go/pkg/parser/modgraph/tidy_test.go @@ -55,6 +55,16 @@ func TestTidyRequireSetViaProxy(t *testing.T) { goMod: "module example.com/testtrans\n\ngo 1.25.0\n\nrequire github.com/stretchr/testify v1.9.0\n", mainGo: "package main\n\nimport _ \"github.com/stretchr/testify/assert\"\n\nfunc main() {}\n", }, + { + // gin genuinely EXERCISES the pruning-completeness promotion: it adds + // kr/text (via gopkg.in/check.v1) and go.uber.org/mock (via a + // dependency package's test) as indirect roots — modules under- + // selected by the pruned graph that the in-memory MVS must promote. + name: "gin_pruning_completeness", + modPath: "example.com/ginapp", + goMod: "module example.com/ginapp\n\ngo 1.25.0\n\nrequire github.com/gin-gonic/gin v1.10.0\n", + mainGo: "package main\n\nimport _ \"github.com/gin-gonic/gin\"\n\nfunc main() {}\n", + }, } httpGet := func(url string) ([]byte, int, error) { From f42ff4b699414c13d7e4ab6507db255041b6735c Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Sat, 20 Jun 2026 02:21:31 -0700 Subject: [PATCH 06/19] Go: scope GoModTidy imports per-module for multi-module repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipe's scan accumulator was global — a single modulePath, rawImports, and requireMods shared across every go.mod in a repository. In a multi-module repo that conflates modules: a nested module's file leaks its imports into the root module's require set. Observed on prometheus, whose root go.mod gained a direct requirement on github.com/grpc-ecosystem/grpc-gateway/v2 — imported only by the nested internal/tools module's `//go:build tools` tools.go — where `go mod tidy` correctly keeps it indirect. Scope the accumulator per module by source path. The scanner now records fileImports keyed by each .go file's source path, plus per-directory module paths and require sets and the set of go.mod directories. The editor attributes each file to its nearest-ancestor go.mod (ownerDir = the longest go.mod directory that is the file's directory or a prefix of it) and tidies each go.mod against only the files it owns. Single-module repositories are unaffected: every file maps to the one root module, identical to before. Validated end to end — prometheus reaches exact parity with `go mod tidy` (grpc-gateway back to indirect) and gin (single module) is unchanged. Unit test TestOwnedImportsScopesByModule guards the attribution. Known remaining nuance: a root-level `//go:build tools` file is still harvested (go mod tidy excludes custom-tag files); a build-constraint-aware import filter is a separate follow-up. --- rewrite-go/pkg/recipe/golang/go_mod_tidy.go | 164 ++++++++++++++---- .../golang/go_mod_tidy_plaintext_test.go | 56 +++++- 2 files changed, 176 insertions(+), 44 deletions(-) diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go index acddcb0a4b8..30c8f9aaf18 100644 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go @@ -60,23 +60,56 @@ func (r *GoModTidy) Description() string { "recompute go.sum, or remove provably-unused indirect requires." } -// tidyAcc is the accumulator threaded across the scan phase. +// tidyAcc is the accumulator threaded across the scan phase. Data is kept +// PER-MODULE (keyed by the directory of each go.mod) so that a repository with +// several modules — e.g. a root module plus a nested `internal/tools` module — +// tidies each go.mod against only its own files. Without this, a nested +// module's imports (such as the classic `//go:build tools` tools.go) would leak +// into the root module's require set and get misclassified as direct. type tidyAcc struct { - // modulePath is the main module's path (from the `module` directive). - modulePath string - // rawImports is every import path seen across the project's .go files. - rawImports map[string]bool - // requireMods is the set of module paths declared in `require`. - requireMods map[string]bool + // goModDirs is the set of directories that contain a go.mod (module roots). + goModDirs map[string]bool + // modulePathByDir maps a module's directory to its declared module path. + modulePathByDir map[string]string + // requireModsByDir maps a module's directory to its declared require set. + requireModsByDir map[string]map[string]bool + // fileImports maps each .go file's source path to the imports it declares. + fileImports map[string][]string } func (r *GoModTidy) InitialValue(ctx *recipe.ExecutionContext) any { return &tidyAcc{ - rawImports: map[string]bool{}, - requireMods: map[string]bool{}, + goModDirs: map[string]bool{}, + modulePathByDir: map[string]string{}, + requireModsByDir: map[string]map[string]bool{}, + fileImports: map[string][]string{}, } } +// sourceDir returns the directory portion of a repo-relative source path +// ("internal/tools/go.mod" -> "internal/tools"; "main.go" -> ""). +func sourceDir(p string) string { + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[:i] + } + return "" +} + +// ownerDir returns the directory of the most-specific module that contains the +// file at fileDir — the longest go.mod directory that is fileDir or an ancestor +// of it. The root module (dir "") owns anything no nested module claims. +func ownerDir(fileDir string, dirs map[string]bool) string { + best, bestLen := "", -1 + for d := range dirs { + if d == "" || fileDir == d || strings.HasPrefix(fileDir, d+"/") { + if len(d) > bestLen { + best, bestLen = d, len(d) + } + } + } + return best +} + func (r *GoModTidy) Scanner(acc any) recipe.TreeVisitor { return visitor.Init(&goModTidyScanner{acc: acc.(*tidyAcc)}) } @@ -108,9 +141,7 @@ type goModTidyScanner struct { func (v *goModTidyScanner) Visit(t java.Tree, p any) java.Tree { if pt, ok := t.(*java.PlainText); ok { if strings.HasSuffix(pt.SourcePath, ".go") { - for _, imp := range parser.FileImports(pt.Text) { - v.acc.rawImports[imp] = true - } + v.acc.fileImports[pt.SourcePath] = parser.FileImports(pt.Text) } return t } @@ -119,33 +150,59 @@ func (v *goModTidyScanner) Visit(t java.Tree, p any) java.Tree { func (v *goModTidyScanner) VisitCompilationUnit(cu *golang.CompilationUnit, p any) java.J { if cu.Imports != nil { + imps := make([]string, 0, len(cu.Imports.Elements)) for _, rp := range cu.Imports.Elements { if path := internal.ImportPath(rp.Element); path != "" { - v.acc.rawImports[path] = true + imps = append(imps, path) } } + v.acc.fileImports[cu.SourcePath] = imps } return v.GoVisitor.VisitCompilationUnit(cu, p) } -func (v *goModTidyScanner) VisitGoModDirective(d *golang.GoModDirective, p any) java.Tree { - switch d.Keyword { - case "module": - if len(d.Values) > 0 { - v.acc.modulePath = d.Values[0].Text - } - case "require": - if len(d.Values) > 0 { - v.acc.requireMods[d.Values[0].Text] = true - } - case "": - // Block entry line: a require entry inside a `require ( … )` block - // has an empty keyword. Record its module path. - if len(d.Values) > 0 && looksLikeModulePath(d.Values[0].Text) { - v.acc.requireMods[d.Values[0].Text] = true +// VisitGoMod records each module's declared path and require set, keyed by the +// go.mod's directory, so the editor can tidy each module against only its own +// files. +func (v *goModTidyScanner) VisitGoMod(gm *golang.GoMod, p any) java.Tree { + dir := sourceDir(gm.SourcePath) + v.acc.goModDirs[dir] = true + modulePath, requires := parseGoModDeclared(gm) + if modulePath != "" { + v.acc.modulePathByDir[dir] = modulePath + } + v.acc.requireModsByDir[dir] = requires + return v.GoVisitor.VisitGoMod(gm, p) +} + +// parseGoModDeclared extracts a go.mod's module path and the set of module paths +// it declares in `require` (both single-line directives and block entries). +func parseGoModDeclared(gm *golang.GoMod) (modulePath string, requires map[string]bool) { + requires = map[string]bool{} + for _, st := range gm.Statements { + switch s := st.Element.(type) { + case *golang.GoModDirective: + switch s.Keyword { + case "module": + if len(s.Values) > 0 { + modulePath = s.Values[0].Text + } + case "require": + if len(s.Values) > 0 { + requires[s.Values[0].Text] = true + } + } + case *golang.GoModBlock: + if s.Keyword == "require" { + for _, e := range s.Entries { + if path, _ := moduleOf(e.Element); path != "" { + requires[path] = true + } + } + } } } - return v.GoVisitor.VisitGoModDirective(d, p) + return modulePath, requires } // --- edit phase --- @@ -153,6 +210,11 @@ func (v *goModTidyScanner) VisitGoModDirective(d *golang.GoModDirective, p any) type goModTidyEditor struct { visitor.GoVisitor acc *tidyAcc + // Per-module scope for the go.mod currently being edited, set at the top of + // VisitGoMod so this module is tidied against only its own files. + curModulePath string + curRequireMods map[string]bool + curImports []string } // reqEntry is a single collected require entry during the rebuild. @@ -162,9 +224,38 @@ type reqEntry struct { indirect bool } +// ownedImports returns the deduped imports of every scanned .go file whose +// nearest-ancestor go.mod is the module at dir — i.e. the files that belong to +// this module and not to a nested one. +func (v *goModTidyEditor) ownedImports(dir string) []string { + seen := map[string]bool{} + var out []string + for path, imps := range v.acc.fileImports { + if ownerDir(sourceDir(path), v.acc.goModDirs) != dir { + continue + } + for _, imp := range imps { + if !seen[imp] { + seen[imp] = true + out = append(out, imp) + } + } + } + return out +} + func (v *goModTidyEditor) VisitGoMod(gm *golang.GoMod, p any) java.Tree { gm = v.GoVisitor.VisitGoMod(gm, p).(*golang.GoMod) + // Scope all import/require data to THIS module's directory. + dir := sourceDir(gm.SourcePath) + v.curModulePath = v.acc.modulePathByDir[dir] + v.curRequireMods = v.acc.requireModsByDir[dir] + if v.curRequireMods == nil { + v.curRequireMods = map[string]bool{} + } + v.curImports = v.ownedImports(dir) + separateIndirect := goVersionAtLeast(gm, 1, 17) // When the go.mod carries a fully-resolved GoResolutionResult, its Requires @@ -441,15 +532,12 @@ func (v *goModTidyEditor) computeTidySet(gm *golang.GoMod, res *golang.GoResolut graph = r } - mainImports := make([]string, 0, len(v.acc.rawImports)) - for imp := range v.acc.rawImports { - mainImports = append(mainImports, imp) - } + mainImports := v.curImports // TidyRequireSet = NeededModules (import-reachable) + the go>=1.17 pruning- // completeness roots (test-transitive indirect deps under-selected by the // pruned graph). The latter requires the go.mod text for synthetic re- // resolution; it no-ops for go<1.17. - rs := modgraph.TidyRequireSet(mainImports, v.acc.modulePath, content, graph, src, separateIndirect) + rs := modgraph.TidyRequireSet(mainImports, v.curModulePath, content, graph, src, separateIndirect) if !rs.Complete { return nil, false } @@ -465,14 +553,14 @@ func (v *goModTidyEditor) computeTidySet(gm *golang.GoMod, res *golang.GoResolut } // directModules returns the set of required module paths that are directly -// imported by some .go file in the project. +// imported by some .go file belonging to the module currently being edited. func (v *goModTidyEditor) directModules() map[string]bool { direct := map[string]bool{} - for imp := range v.acc.rawImports { - if internal.IsStdlib(imp) || internal.IsLocal(imp, v.acc.modulePath) { + for _, imp := range v.curImports { + if internal.IsStdlib(imp) || internal.IsLocal(imp, v.curModulePath) { continue } - if m := providingModule(imp, v.acc.requireMods); m != "" { + if m := providingModule(imp, v.curRequireMods); m != "" { direct[m] = true } } diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go index 9728ca9808c..6f6d2389087 100644 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go @@ -22,6 +22,15 @@ import ( "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" ) +func hasImport(imps []string, want string) bool { + for _, i := range imps { + if i == want { + return true + } + } + return false +} + // TestScannerHarvestsPlainTextGoImports verifies the scan phase reads imports // out of a build-excluded `.go` file that the CLI represented as PlainText // (e.g. a `//go:build windows` file on Linux), so go mod tidy doesn't prune a @@ -36,11 +45,12 @@ func TestScannerHarvestsPlainTextGoImports(t *testing.T) { Text: "//go:build windows\n\npackage sys\n\nimport (\n\t\"golang.org/x/sys/windows\"\n\t\"fmt\"\n)\n", }, nil) - if !acc.rawImports["golang.org/x/sys/windows"] { - t.Errorf("expected windows-only import to be harvested from PlainText; got %v", acc.rawImports) + imps := acc.fileImports["internal/sys_windows.go"] + if !hasImport(imps, "golang.org/x/sys/windows") { + t.Errorf("expected windows-only import to be harvested from PlainText; got %v", imps) } - if !acc.rawImports["fmt"] { - t.Errorf("expected fmt import to be harvested from PlainText; got %v", acc.rawImports) + if !hasImport(imps, "fmt") { + t.Errorf("expected fmt import to be harvested from PlainText; got %v", imps) } } @@ -56,7 +66,41 @@ func TestScannerIgnoresNonGoPlainText(t *testing.T) { Text: "import \"this is not go\"\n", }, nil) - if len(acc.rawImports) != 0 { - t.Errorf("expected no imports harvested from a non-.go PlainText; got %v", acc.rawImports) + if len(acc.fileImports) != 0 { + t.Errorf("expected no imports harvested from a non-.go PlainText; got %v", acc.fileImports) + } +} + +// TestOwnedImportsScopesByModule verifies a nested module's files are NOT +// attributed to the root module — the prometheus `internal/tools` regression, +// where a nested `//go:build tools` file's import leaked into the root go.mod +// and was misclassified as a direct dependency. +func TestOwnedImportsScopesByModule(t *testing.T) { + acc := &tidyAcc{ + goModDirs: map[string]bool{"": true, "internal/tools": true}, + modulePathByDir: map[string]string{"": "example.com/root", "internal/tools": "example.com/root/internal/tools"}, + requireModsByDir: map[string]map[string]bool{}, + fileImports: map[string][]string{ + "main.go": {"github.com/spf13/cobra"}, + "internal/tools/tools.go": {"github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"}, + "internal/helper/helper.go": {"github.com/root/own"}, // belongs to root (no nested go.mod here) + }, + } + ed := &goModTidyEditor{acc: acc} + + root := ed.ownedImports("") + if !hasImport(root, "github.com/spf13/cobra") || !hasImport(root, "github.com/root/own") { + t.Errorf("root module should own main.go and internal/helper imports; got %v", root) + } + if hasImport(root, "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway") { + t.Errorf("root module must NOT own the nested internal/tools import; got %v", root) + } + + tools := ed.ownedImports("internal/tools") + if !hasImport(tools, "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway") { + t.Errorf("internal/tools module should own tools.go import; got %v", tools) + } + if hasImport(tools, "github.com/spf13/cobra") { + t.Errorf("internal/tools module must NOT own root imports; got %v", tools) } } From 9dd862dc35ac0d2c33421916ca612071a612fa06 Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Sat, 20 Jun 2026 02:47:28 -0700 Subject: [PATCH 07/19] Go: process pruning-completeness roots frontier-by-frontier (depth order) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pruning-completeness pass promoted every under-selected reachable module in one pass and iterated to a fixpoint. That over-includes: a module reached only through a deeper dependency (e.g. github.com/kr/text, required by kr/pretty, required by gopkg.in/check.v1) looks under-selected until its requirer is itself promoted — so promoting them together wrongly keeps the deeper one. `go mod tidy` records kr/pretty but not kr/text; the old pass recorded both (observed on sourcegraph/conc, and version/cache-state dependent because the result hinged on iteration order). Mirror cmd/go/internal/modload.tidyPrunedRoots: walk the package import graph frontier by frontier in increasing import-stack depth, recomputing the pruned selection between frontiers, and promote a module only when it is still under-selected at its depth. Promoting a shallow root (kr/pretty) then pins its requirements (kr/text) before they are examined, so they are not promoted. A package's test imports are deferred one frontier deeper (go's `.test` node), keeping test-transitive deps below ordinary ones. The in-memory pruned selection is recomputed per frontier (cheap; no re-resolution). Validated: new golden case conc_depth_ordering matches `go mod tidy` (kr/pretty kept, kr/text excluded); gin/cli promotion cases and live CLI runs on conc/gin/cli/zap all reach exact parity. --- rewrite-go/pkg/parser/modgraph/tidy.go | 156 ++++++++++---------- rewrite-go/pkg/parser/modgraph/tidy_test.go | 29 +++- 2 files changed, 102 insertions(+), 83 deletions(-) diff --git a/rewrite-go/pkg/parser/modgraph/tidy.go b/rewrite-go/pkg/parser/modgraph/tidy.go index 9081f3f7c38..b7bf579c588 100644 --- a/rewrite-go/pkg/parser/modgraph/tidy.go +++ b/rewrite-go/pkg/parser/modgraph/tidy.go @@ -51,15 +51,27 @@ func TidyRequireSet(mainImports []string, mainModulePath, mainGoMod string, res // loaded[path] = the version each module is selected at in the full build // list (go's `m.Version` — the version a package's module is loaded at). loaded := map[string]string{} + var mods []modVer for _, m := range res.BuildList { if !m.Main { loaded[m.Path] = m.Version + mods = append(mods, modVer{m.Path, m.Version}) } } - - // Reachable modules via imports AND tests, starting from `all`. Their - // providing modules are the candidates that may need explicit roots. - reachable := testReachableModules(mainImports, mainModulePath, res, src) + moduleOf := func(importPath string) (string, string) { + best, bestVer := "", "" + for _, m := range mods { + if importPath == m.path || strings.HasPrefix(importPath, m.path+"/") { + if len(m.path) > len(best) { + best, bestVer = m.path, m.version + } + } + } + return best, bestVer + } + isLocal := func(importPath string) bool { + return importPath == mainModulePath || strings.HasPrefix(importPath, mainModulePath+"/") + } // Current root set: everything NeededModules already requires. roots := map[string]string{} @@ -76,28 +88,68 @@ func TidyRequireSet(mainImports []string, mainModulePath, mainGoMod string, res // entirely in memory — no per-iteration re-resolution. idx := newReqIndex(res, mainGoMod, src) - // Iterate: under the current roots' PRUNED graph, promote any reachable - // module that is under-selected to a root. Adding roots raises selections, - // so repeat to a fixpoint (bounded by the number of candidates). - for { + // Walk the package import graph FRONTIER BY FRONTIER, by increasing import- + // stack depth, mirroring cmd/go/internal/modload.tidyPrunedRoots. At each + // frontier we recompute the pruned selection under the roots accumulated so + // far, then promote any frontier module the pruned graph under-selects. This + // ordering is essential: promoting a shallow module (e.g. kr/pretty) pins its + // deeper requirements (kr/text) BEFORE they are examined, so they are not + // wrongly promoted. A package's TEST imports are deferred one frontier deeper + // (go models this as a separate `.test` node) so test-transitive deps + // sort below ordinary ones. + type qitem struct { + path string + isTest bool + } + queued := map[string]bool{} + var queue []qitem + enq := func(path string, isTest bool) { + if isStdlibImport(path) || isLocal(path) { + return + } + k := path + if isTest { + k += "\x00t" + } + if !queued[k] { + queued[k] = true + queue = append(queue, qitem{path, isTest}) + } + } + for _, imp := range mainImports { + enq(imp, false) + } + + for len(queue) > 0 { sel := prunedSelectInMemory(roots, idx) - added := false - for path := range reachable { - if _, isRoot := roots[path]; isRoot { + frontier := queue + queue = nil + for _, it := range frontier { + mod, ver := moduleOf(it.path) + if mod == "" { continue } - want, have := loaded[path], sel[path] - if want == "" { - continue + imports, testImports, err := packageImportsWithTests(src, mod, ver, it.path) + if err == nil { + if it.isTest { + for _, d := range testImports { + enq(d, false) + } + } else { + for _, d := range imports { + enq(d, false) + } + enq(it.path, true) // the package's test node, one frontier deeper + } } - if have == "" || semver.Compare(have, want) < 0 { - roots[path] = want - added = true + if _, isRoot := roots[mod]; !isRoot { + want := loaded[mod] + have := sel[mod] + if want != "" && (have == "" || semver.Compare(have, want) < 0) { + roots[mod] = want + } } } - if !added { - break - } } // Reclassify: direct stays direct; every other root is indirect. @@ -257,65 +309,5 @@ func prunedSelectInMemory(roots map[string]string, idx *reqIndex) map[string]str return present } -// testReachableModules returns the set of module paths reachable from the main -// module's imports by following BOTH ordinary and test imports, recursively. It -// maps each reached package to its providing build-list module. This is the set -// of modules `go mod tidy` considers when deciding which need explicit roots for -// the pruned graph to be reproducible. -func testReachableModules(mainImports []string, mainModulePath string, res Result, src ModSource) map[string]bool { - type modver struct{ path, version string } - var mods []modver - for _, m := range res.BuildList { - if !m.Main { - mods = append(mods, modver{m.Path, m.Version}) - } - } - moduleOf := func(importPath string) (string, string) { - best, bestVer := "", "" - for _, m := range mods { - if importPath == m.path || strings.HasPrefix(importPath, m.path+"/") { - if len(m.path) > len(best) { - best, bestVer = m.path, m.version - } - } - } - return best, bestVer - } - isLocal := func(importPath string) bool { - return importPath == mainModulePath || strings.HasPrefix(importPath, mainModulePath+"/") - } - - out := map[string]bool{} - visited := map[string]bool{} - var queue []string - queue = append(queue, mainImports...) - - for len(queue) > 0 { - imp := queue[0] - queue = queue[1:] - if visited[imp] || isStdlibImport(imp) || isLocal(imp) { - continue - } - visited[imp] = true - m, ver := moduleOf(imp) - if m == "" { - continue - } - out[m] = true - imports, testImports, err := packageImportsWithTests(src, m, ver, imp) - if err != nil { - continue - } - for _, dep := range imports { - if !visited[dep] { - queue = append(queue, dep) - } - } - for _, dep := range testImports { - if !visited[dep] { - queue = append(queue, dep) - } - } - } - return out -} +// modVer is a build-list module path at its selected version. +type modVer struct{ path, version string } diff --git a/rewrite-go/pkg/parser/modgraph/tidy_test.go b/rewrite-go/pkg/parser/modgraph/tidy_test.go index 9921340985c..01863f4caa7 100644 --- a/rewrite-go/pkg/parser/modgraph/tidy_test.go +++ b/rewrite-go/pkg/parser/modgraph/tidy_test.go @@ -36,7 +36,7 @@ func TestTidyRequireSetViaProxy(t *testing.T) { t.Skip("needs network + the go toolchain") } cases := []struct { - name, modPath, goMod, mainGo string + name, modPath, goMod, mainGo, testGo string }{ { // No pruning-completeness extras: indirect == import-reachable only. @@ -65,6 +65,30 @@ func TestTidyRequireSetViaProxy(t *testing.T) { goMod: "module example.com/ginapp\n\ngo 1.25.0\n\nrequire github.com/gin-gonic/gin v1.10.0\n", mainGo: "package main\n\nimport _ \"github.com/gin-gonic/gin\"\n\nfunc main() {}\n", }, + { + // conc shape: testify is imported by a TEST of the main module (not + // main code), with the older testify v1.8.1. `go mod tidy` does NOT + // record kr/text here (the pruned graph already selects it correctly), + // so the version gate must NOT over-promote it. Guards the conc 0/1 + // regression. + name: "test_in_test_file", + modPath: "example.com/conctest", + goMod: "module example.com/conctest\n\ngo 1.20\n\nrequire github.com/stretchr/testify v1.8.1\n", + mainGo: "package conctest\n", + testGo: "package conctest\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestX(t *testing.T) { assert.Equal(t, 1, 1) }\n", + }, + { + // The exact conc go.mod, which exercises the DEPTH-ORDERING rule: + // check.v1 -> kr/pretty -> kr/text. `go mod tidy` promotes kr/pretty + // (under-selected) but NOT kr/text (kr/pretty's go.mod pins it once + // kr/pretty is a root). A non-frontier-ordered pass over-promotes + // kr/text; this guards that the BFS pins it first. + name: "conc_depth_ordering", + modPath: "github.com/sourcegraph/conc", + goMod: "module github.com/sourcegraph/conc\n\ngo 1.20\n\nrequire github.com/stretchr/testify v1.8.1\n\nrequire (\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/kr/pretty v0.3.0 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/rogpeppe/go-internal v1.9.0 // indirect\n\tgopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n", + mainGo: "package conc\n", + testGo: "package conc\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestX(t *testing.T) { assert.Equal(t, 1, 1) }\n", + }, } httpGet := func(url string) ([]byte, int, error) { @@ -82,6 +106,9 @@ func TestTidyRequireSetViaProxy(t *testing.T) { dir := t.TempDir() write(t, dir, "go.mod", tc.goMod) write(t, dir, "main.go", tc.mainGo) + if tc.testGo != "" { + write(t, dir, "main_test.go", tc.testGo) + } runGo(t, dir, "mod", "tidy") wantDirect, wantIndirect := goldenRequires(t, dir) mainImports := scanMainImports(t, dir) From b302dc539d27ddfa872a79e9db5ebc343cacee55 Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Sat, 20 Jun 2026 03:23:09 -0700 Subject: [PATCH 08/19] Go: tidy GoModTidy after the recipe gained full module-graph resolution Housekeeping for code paths obsoleted by this branch's work; no behavior change. - Remove looksLikeModulePath, dead since the scanner moved to parseGoModDeclared (which classifies require-block entries via moduleOf) for per-module scoping. - Drop findResolution, an exact duplicate of GetResolutionResult in the same package; use the latter. - Rewrite the GoModTidy type doc and Description: they still claimed the recipe does NOT add missing requires, remove unused ones, or do MVS version selection. It now does all of that via TidyRequireSet; only go.sum is left alone. DisplayName drops the now-inaccurate "(LST-only)" suffix. - Clarify the editor's three-tier fallback comment (the marker fallback is a parse-time-resolution path, not test-only). --- rewrite-go/pkg/recipe/golang/go_mod_tidy.go | 69 +++++++++------------ 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go index 30c8f9aaf18..33592b33bbb 100644 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go @@ -33,31 +33,33 @@ import ( "github.com/openrewrite/rewrite/rewrite-go/pkg/visitor" ) -// GoModTidy emulates the go.mod-affecting behavior of `go mod tidy` that is -// decidable from the parsed LST alone (no module graph / network access): +// GoModTidy emulates `go mod tidy`'s effect on go.mod. It computes the exact +// require set the toolchain would write — adding missing requires, removing +// unused ones, classifying each as direct or `// indirect`, and including the +// go>=1.17 pruning-completeness roots — then rewrites the `require` blocks +// (sorted by module path/version, the toolchain's key). // -// - Re-marks each `require` entry as direct (no comment) or `// indirect` -// based on whether the module is imported by any .go file in the module. -// - Sorts entries within each `require` block lexicographically by module -// path (then version), the same key the go toolchain uses. +// The require set is computed at recipe time from the resolved module graph, +// the imports scanned from the project's .go files, and a dependency ModSource +// (the local module cache plus a write-through GOPROXY reached over the CLI +// HttpSender). When no source is available or resolution cannot complete (e.g. +// offline), it degrades to an LST-only pass that re-marks and sorts the +// existing requires without dropping anything. go.sum is NOT recomputed. // -// It does NOT add missing requires, recompute go.sum, remove provably-unused -// indirect requires, or perform MVS version selection — those require the -// module graph and are out of scope for this LST-only phase. -// -// GoModTidy is a ScanningRecipe: the scan phase collects the set of imported -// module paths across every .go file in the project, then the edit phase -// rewrites the sibling go.mod. +// GoModTidy is a ScanningRecipe: the scan phase records, per module, the +// imports of every .go file it owns; the edit phase rewrites each go.mod +// against only its own module's files (so multi-module repositories tidy +// each go.mod independently). type GoModTidy struct { recipe.ScanningBase } func (r *GoModTidy) Name() string { return "org.openrewrite.golang.GoModTidy" } -func (r *GoModTidy) DisplayName() string { return "Tidy go.mod (LST-only)" } +func (r *GoModTidy) DisplayName() string { return "Tidy go.mod" } func (r *GoModTidy) Description() string { - return "Emulate the subset of `go mod tidy` that is decidable from source alone: re-mark `// indirect` " + - "requires from the import graph and sort `require` blocks. Does not add missing requires, " + - "recompute go.sum, or remove provably-unused indirect requires." + return "Emulate `go mod tidy`'s effect on go.mod: add missing requires, remove unused ones, " + + "classify each as direct or `// indirect` (including go>=1.17 pruning-completeness roots), " + + "and sort the `require` blocks. Does not recompute go.sum." } // tidyAcc is the accumulator threaded across the scan phase. Data is kept @@ -263,7 +265,7 @@ func (v *goModTidyEditor) VisitGoMod(gm *golang.GoMod, p any) java.Tree { // parse time (already pruned of unused modules, with missing ones added and // // indirect classified). Otherwise fall back to the LST-only Phase 1: // re-mark and sort the existing requires from the import scan. - res := findResolution(gm) + res := GetResolutionResult(gm) authoritative := res != nil && res.GraphComplete && len(res.Requires) > 0 // Strip the existing require statements, remembering where the first one @@ -305,12 +307,14 @@ func (v *goModTidyEditor) VisitGoMod(gm *golang.GoMod, p any) java.Tree { } // Determine the entries to emit, in priority order: - // 1. Compute the tidy require set NOW from the parse-time-resolved module - // graph + the scanned imports + a ModSource (cache/proxy). This is the - // production path: the marker carries the declared requires and the - // resolved graph, and the precise set is computed at recipe time. - // 2. Use a pre-computed authoritative require set on the marker (tests). - // 3. LST-only Phase 1: re-mark and sort the declared requires. + // 1. Compute the exact tidy require set NOW from the resolved module graph, + // the scanned imports, and a ModSource (cache + write-through proxy). + // This is the production path. + // 2. Fall back to the require set the marker carries from parse-time + // resolution, used when (1) cannot complete at recipe time but parse time + // did resolve the full graph. + // 3. LST-only: re-mark the declared requires by direct-import and sort them, + // preserving the existing set (the offline / no-ModSource degradation). var entries []reqEntry if computed, ok := v.computeTidySet(gm, res, separateIndirect, p); ok { entries = computed @@ -465,17 +469,6 @@ func goVersionAtLeast(gm *golang.GoMod, major, minor int) bool { return true } -// findResolution returns the GoResolutionResult marker attached to the go.mod, -// or nil if none is present. -func findResolution(gm *golang.GoMod) *golang.GoResolutionResult { - for i := range gm.Markers.Entries { - if r, ok := gm.Markers.Entries[i].(golang.GoResolutionResult); ok { - return &r - } - } - return nil -} - // headerInsertIndex returns the index just after the last leading header // directive (module / go / toolchain) in stmts, the natural spot to insert a // require block when none exists yet. @@ -615,9 +608,3 @@ func providingModule(importPath string, requireMods map[string]bool) string { } return best } - -// looksLikeModulePath is a cheap heuristic to distinguish a require entry's -// module path token from other directive tokens (versions, operators). -func looksLikeModulePath(s string) bool { - return strings.Contains(s, ".") && !strings.HasPrefix(s, "v") && s != "=>" -} From c0ace04496fa9825960b274e8889dc1a3b080cf8 Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Mon, 22 Jun 2026 15:29:54 -0700 Subject: [PATCH 09/19] Go: remove dead PlainText paths; recover imports from build-excluded files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes to GoModTidy's view of the source. Remove the dead PlainText handling. The recipe registered org.openrewrite.text. PlainText and harvested imports from PlainText .go files on the assumption that the CLI's Go build step backfilled a PlainText for any file the parser omitted. It does not — a build-excluded file simply vanishes from the LST (verified). So the language registration, the scanner's PlainText branch, the receiver's PlainText codec, the PlainText tree type, its value-type factory, and parser.FileImports were all unreachable. Removed. Recover platform-gated imports in the parser instead (the robust fix the PlainText path was meant to be). `go mod tidy` unions imports across every GOOS/GOARCH and tag, so a module imported only by, say, a //go:build windows file (cobra's mousetrap) must stay visible. ParsePackage now parses build-excluded files IMPORTS-ONLY and emits a small CompilationUnit carrying just their imports, so the recipe counts them via cu.Imports with no marker or cross-repo plumbing. Imports-only is essential: a full body cannot be mapped without type info (the mapper needs types to tell a `(T)` conversion from a parenthesized expression, which otherwise yields a J$Parentheses where Java expects a TypeTree). Excluded files are type-checked only with the included set; those with no imports, or that fail the imports-only parse, are dropped rather than surfaced as spurious ParseErrors. They are never modified by the recipe, so they are not written back. Tests updated: build-constraint evaluation is now exercised via MatchBuildContext (ParsePackage no longer omits), and the omit-test becomes an emit-test asserting a //go:build windows file's import survives. --- rewrite-go/cmd/rpc/main.go | 5 - rewrite-go/pkg/parser/go_parser.go | 135 +++++++++++------- rewrite-go/pkg/recipe/golang/go_mod_tidy.go | 32 ++--- ...text_test.go => go_mod_tidy_scope_test.go} | 50 +------ rewrite-go/pkg/rpc/go_receiver.go | 65 +-------- rewrite-go/pkg/rpc/value_types.go | 6 +- rewrite-go/pkg/tree/java/plain_text.go | 43 ------ rewrite-go/test/build_tags_test.go | 23 ++- rewrite-go/test/file_imports_test.go | 57 -------- rewrite-go/test/parse_failure_test.go | 42 ++++-- 10 files changed, 134 insertions(+), 324 deletions(-) rename rewrite-go/pkg/recipe/golang/{go_mod_tidy_plaintext_test.go => go_mod_tidy_scope_test.go} (54%) delete mode 100644 rewrite-go/pkg/tree/java/plain_text.go delete mode 100644 rewrite-go/test/file_imports_test.go diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index 52a5d0282c6..f93b01e3637 100644 --- a/rewrite-go/cmd/rpc/main.go +++ b/rewrite-go/cmd/rpc/main.go @@ -475,11 +475,6 @@ func (s *server) handleGetLanguages() []string { return []string{ "org.openrewrite.golang.tree.Go$CompilationUnit", "org.openrewrite.golang.tree.GoMod", - // PlainText is the CLI Go build step's fallback for any .go file the - // parser doesn't return (e.g. files excluded by the host build - // context). Accepting it lets GoModTidy read those files' imports — - // `go mod tidy` unions imports across all build configurations. - "org.openrewrite.text.PlainText", } } diff --git a/rewrite-go/pkg/parser/go_parser.go b/rewrite-go/pkg/parser/go_parser.go index 4120be0fa26..1fa9d2a3de7 100644 --- a/rewrite-go/pkg/parser/go_parser.go +++ b/rewrite-go/pkg/parser/go_parser.go @@ -74,31 +74,6 @@ type FileInput struct { Content string } -// FileImports returns the import paths declared in a single Go source file, -// parsed imports-only. Unlike ParsePackage it ignores build constraints and -// never type-checks, so it works for any file regardless of the host platform -// — useful for unioning imports across build configurations the way -// `go mod tidy` does (e.g. reading the dependencies of a `//go:build windows` -// file on Linux, where it would otherwise be excluded from the LST). Returns -// nil on parse error. -func FileImports(content string) []string { - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, "", content, parser.ImportsOnly) - if err != nil || f == nil { - return nil - } - out := make([]string, 0, len(f.Imports)) - for _, spec := range f.Imports { - if spec.Path == nil { - continue - } - if p, err := strconv.Unquote(spec.Path.Value); err == nil && p != "" { - out = append(out, p) - } - } - return out -} - // Parse parses a single Go source file and returns its CompilationUnit. // Convenience wrapper around ParsePackage for the common one-file case; // type attribution that depends on sibling files in the same package @@ -133,8 +108,17 @@ func (gp *GoParser) Parse(sourcePath string, source string) (*golang.Compilation // it does not. A single malformed file never takes down its siblings and // never silently disappears — it travels as a ParseError so the Java side // represents it as such instead of falling back to a PlainText/Quark. -// Files the build context excludes (`//go:build` / OS-arch suffix) are -// omitted: they are not part of this build, which is not a parse failure. +// +// Files the build context excludes (`//go:build` / OS-arch suffix) ARE still +// parsed and returned as CompilationUnits — `go mod tidy` unions imports across +// every GOOS/GOARCH and tag, so a module imported only by, say, a +// `//go:build windows` file (cobra's mousetrap) must still be visible. Build +// exclusion concerns build INCLUSION, not parseability: such files are valid Go +// and round-trip losslessly. They are parsed fully but type-checked only with +// the build-included set (their platform-specific symbols would not resolve +// here), so their syntax/imports are exact while type attribution is partial. +// An excluded file that fails to parse is dropped (it is not part of this +// build), rather than surfaced as a ParseError. func (gp *GoParser) ParsePackage(files []FileInput) []java.Tree { if len(files) == 0 { return nil @@ -142,46 +126,67 @@ func (gp *GoParser) ParsePackage(files []FileInput) []java.Tree { fset := token.NewFileSet() - // One outcome per build-included file, in input order: a parsed AST or - // the parse error. Build-excluded files are not represented at all. + // One outcome per file, in input order: a parsed AST (build-included or not) + // or, for an included file, the parse error. type outcome struct { - input FileInput - ast *ast.File // nil when err != nil - err error + input FileInput + ast *ast.File // nil when err != nil + err error + excluded bool // parsed for its imports, but not part of this build } outcomes := make([]outcome, 0, len(files)) - asts := make([]*ast.File, 0, len(files)) + asts := make([]*ast.File, 0, len(files)) // only build-included files get type-checked for _, f := range files { - if !MatchBuildContext(gp.BuildContext, filepath.Base(f.Path), f.Content) { + included := MatchBuildContext(gp.BuildContext, filepath.Base(f.Path), f.Content) + // Build-included files are parsed fully (and type-checked) so they map to + // faithful, type-attributed CompilationUnits. Build-EXCLUDED files are + // parsed IMPORTS-ONLY: we only need their imports for `go mod tidy`, and + // a full body cannot be mapped correctly without type info (e.g. the + // mapper needs types to tell a `(T)` conversion from a parenthesized + // expression). An imports-only AST has no such ambiguous constructs, so + // it maps to a small but valid CompilationUnit carrying just the imports. + mode := parser.ParseComments + if !included { + mode = parser.ImportsOnly + } + a, err := parser.ParseFile(fset, f.Path, f.Content, mode) + if err != nil { + if included { + outcomes = append(outcomes, outcome{input: f, err: err}) + } + // A build-excluded file that won't parse is simply dropped. continue } - a, err := parser.ParseFile(fset, f.Path, f.Content, parser.ParseComments) - if err != nil { - outcomes = append(outcomes, outcome{input: f, err: err}) + // A build-excluded file is only worth emitting for its imports; drop it + // when it has none (it contributes nothing to `go mod tidy` and would + // otherwise add an empty CompilationUnit to the LST). + if !included && len(a.Imports) == 0 { continue } - outcomes = append(outcomes, outcome{input: f, ast: a}) - asts = append(asts, a) + outcomes = append(outcomes, outcome{input: f, ast: a, excluded: !included}) + if included { + asts = append(asts, a) + } } if len(outcomes) == 0 { return nil } - // Type-check every file that parsed, together, so cross-file references - // resolve. Skipped entirely when nothing parsed (all inputs failed). - var typeInfo *types.Info - var mapper *typeMapper + // Type-check the build-included files together so cross-file references + // resolve. typeInfo/mapper are always created (possibly empty, e.g. when a + // package's files are all build-excluded) so every outcome can be mapped. + typeInfo := &types.Info{ + Types: make(map[ast.Expr]types.TypeAndValue), + Defs: make(map[*ast.Ident]types.Object), + Uses: make(map[*ast.Ident]types.Object), + Selections: make(map[*ast.SelectorExpr]*types.Selection), + // Instances records identifiers denoting generic functions/types that are + // instantiated with explicit type arguments, e.g. the `Map` in `Map[int]`. + // Used to distinguish generic instantiation from ordinary indexing. + Instances: make(map[*ast.Ident]types.Instance), + } + mapper := newTypeMapper() if len(asts) > 0 { - typeInfo = &types.Info{ - Types: make(map[ast.Expr]types.TypeAndValue), - Defs: make(map[*ast.Ident]types.Object), - Uses: make(map[*ast.Ident]types.Object), - Selections: make(map[*ast.SelectorExpr]*types.Selection), - // Instances records identifiers denoting generic functions/types that are - // instantiated with explicit type arguments, e.g. the `Map` in `Map[int]`. - // Used to distinguish generic instantiation from ordinary indexing. - Instances: make(map[*ast.Ident]types.Instance), - } conf := types.Config{ Importer: gp.Importer, // Don't fail on type errors — we want partial type info even when @@ -201,7 +206,6 @@ func (gp *GoParser) ParsePackage(files []FileInput) []java.Tree { } } _, _ = conf.Check(pkgName, fset, asts, typeInfo) - mapper = newTypeMapper() } out := make([]java.Tree, 0, len(outcomes)) @@ -219,6 +223,18 @@ func (gp *GoParser) ParsePackage(files []FileInput) []java.Tree { typeInfo: typeInfo, mapper: mapper, } + if o.excluded { + // Build-excluded files are parsed only to recover their imports for + // `go mod tidy`; they are not type-checked, so a node-level type + // lookup during mapping may be nil and panic. Recover and drop the + // file — it is not part of this build, so a spurious ParseError + // would be wrong. (Its imports are lost only when mapping fails, + // which is rare and almost always redundant with included files.) + if cu := ctx.safeMapFile(o.ast, o.input.Path); cu != nil { + out = append(out, cu) + } + continue + } out = append(out, ctx.mapFile(o.ast, o.input.Path)) } return out @@ -273,6 +289,17 @@ func (ctx *parseContext) prefixAndSkip(pos token.Pos, length int) java.Space { } // mapFile maps an ast.File to a CompilationUnit. +// safeMapFile maps a file to a CompilationUnit, returning nil if mapping panics +// (used for build-excluded files, which lack type info — see ParsePackage). +func (ctx *parseContext) safeMapFile(file *ast.File, sourcePath string) (cu *golang.CompilationUnit) { + defer func() { + if recover() != nil { + cu = nil + } + }() + return ctx.mapFile(file, sourcePath) +} + func (ctx *parseContext) mapFile(file *ast.File, sourcePath string) *golang.CompilationUnit { // "package" keyword prefix := ctx.prefixAndSkip(file.Package, len("package")) diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go index 33592b33bbb..c2169414bf5 100644 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go @@ -23,7 +23,6 @@ import ( "github.com/google/uuid" - "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" @@ -122,34 +121,21 @@ func (r *GoModTidy) EditorWithData(acc any) recipe.TreeVisitor { // --- scan phase --- // -// `go mod tidy` unions imports across ALL build configurations (every -// GOOS/GOARCH and build tag). The Go parser type-checks under a single host -// build context, so files excluded by that context (e.g. a `//go:build -// windows` file on Linux) are not parsed into a Go.CompilationUnit — the CLI -// represents them as PlainText instead. To avoid misclassifying their -// dependencies as unused, the scanner also reads imports out of PlainText -// `.go` files (parsed imports-only, which is platform-independent). This -// recovers platform-gated imports without the full cost/ambiguity of -// per-configuration type-checking. +// The scanner records, per source file, the imports of each Go.CompilationUnit, +// plus each module's declared path and require set. Note that `go mod tidy` +// unions imports across ALL build configurations (every GOOS/GOARCH and tag), +// whereas the parser type-checks under one host build context — so files the +// host context excludes (e.g. a `//go:build windows` file on Linux) are not +// parsed into a CompilationUnit and their imports are not scanned here. That is +// an accepted limitation: it only matters for a module imported SOLELY by a +// platform-gated file, which is rare. (Recovering those would mean parsing the +// excluded files imports-only — see ParsePackage, which currently omits them.) type goModTidyScanner struct { visitor.GoVisitor acc *tidyAcc } -// Visit intercepts PlainText source files — which the framework's Go dispatch -// has no case for — to harvest imports from build-excluded `.go` files. Every -// other tree falls through to the normal Go dispatch. -func (v *goModTidyScanner) Visit(t java.Tree, p any) java.Tree { - if pt, ok := t.(*java.PlainText); ok { - if strings.HasSuffix(pt.SourcePath, ".go") { - v.acc.fileImports[pt.SourcePath] = parser.FileImports(pt.Text) - } - return t - } - return v.GoVisitor.Visit(t, p) -} - func (v *goModTidyScanner) VisitCompilationUnit(cu *golang.CompilationUnit, p any) java.J { if cu.Imports != nil { imps := make([]string, 0, len(cu.Imports.Elements)) diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy_scope_test.go similarity index 54% rename from rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go rename to rewrite-go/pkg/recipe/golang/go_mod_tidy_scope_test.go index 6f6d2389087..5fad94a3df8 100644 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy_plaintext_test.go +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy_scope_test.go @@ -16,11 +16,7 @@ package golang -import ( - "testing" - - "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" -) +import "testing" func hasImport(imps []string, want string) bool { for _, i := range imps { @@ -31,50 +27,6 @@ func hasImport(imps []string, want string) bool { return false } -// TestScannerHarvestsPlainTextGoImports verifies the scan phase reads imports -// out of a build-excluded `.go` file that the CLI represented as PlainText -// (e.g. a `//go:build windows` file on Linux), so go mod tidy doesn't prune a -// platform-only dependency it can no longer "see". -func TestScannerHarvestsPlainTextGoImports(t *testing.T) { - r := &GoModTidy{} - acc := r.InitialValue(nil).(*tidyAcc) - scan := r.Scanner(acc) - - scan.Visit(&java.PlainText{ - SourcePath: "internal/sys_windows.go", - Text: "//go:build windows\n\npackage sys\n\nimport (\n\t\"golang.org/x/sys/windows\"\n\t\"fmt\"\n)\n", - }, nil) - - imps := acc.fileImports["internal/sys_windows.go"] - if !hasImport(imps, "golang.org/x/sys/windows") { - t.Errorf("expected windows-only import to be harvested from PlainText; got %v", imps) - } - if !hasImport(imps, "fmt") { - t.Errorf("expected fmt import to be harvested from PlainText; got %v", imps) - } -} - -// TestScannerIgnoresNonGoPlainText verifies non-.go PlainText files (README, -// go.sum, …) are left alone — only `.go` text is parsed for imports. -func TestScannerIgnoresNonGoPlainText(t *testing.T) { - r := &GoModTidy{} - acc := r.InitialValue(nil).(*tidyAcc) - scan := r.Scanner(acc) - - scan.Visit(&java.PlainText{ - SourcePath: "README.md", - Text: "import \"this is not go\"\n", - }, nil) - - if len(acc.fileImports) != 0 { - t.Errorf("expected no imports harvested from a non-.go PlainText; got %v", acc.fileImports) - } -} - -// TestOwnedImportsScopesByModule verifies a nested module's files are NOT -// attributed to the root module — the prometheus `internal/tools` regression, -// where a nested `//go:build tools` file's import leaked into the root go.mod -// and was misclassified as a direct dependency. func TestOwnedImportsScopesByModule(t *testing.T) { acc := &tidyAcc{ goModDirs: map[string]bool{"": true, "internal/tools": true}, diff --git a/rewrite-go/pkg/rpc/go_receiver.go b/rewrite-go/pkg/rpc/go_receiver.go index 3ec72003328..a21e432caeb 100644 --- a/rewrite-go/pkg/rpc/go_receiver.go +++ b/rewrite-go/pkg/rpc/go_receiver.go @@ -54,10 +54,6 @@ func (r *GoReceiver) Visit(t java.Tree, p any) java.Tree { c := *pe return r.receiveParseError(&c, p.(*ReceiveQueue)) } - if pt, ok := t.(*java.PlainText); ok { - c := *pt - return r.receivePlainText(&c, p.(*ReceiveQueue)) - } if gm, ok := t.(*golang.GoMod); ok { c := *gm return receiveGoMod(&c, p.(*ReceiveQueue)) @@ -81,7 +77,7 @@ func (r *GoReceiver) receiveParseError(pe *java.ParseError, q *ReceiveQueue) *ja } } // markers is a nested object: consume its envelope via q.Receive, then read - // sub-fields in the onChange (matches JavaReceiver.PreVisit / receivePlainText). + // sub-fields in the onChange (matches JavaReceiver.PreVisit). // A direct receiveMarkersCodec call skips the envelope and desyncs the queue // on any marker-bearing ParseError. if result := q.Receive(pe.Markers, func(v any) any { @@ -100,65 +96,6 @@ func (r *GoReceiver) receiveParseError(pe *java.ParseError, q *ReceiveQueue) *ja return pe } -// receivePlainText deserializes a PlainText matching the canonical field order -// in org.openrewrite.text.PlainTextRpcCodec (and rewrite-javascript's -// text/rpc.ts): id, markers, sourcePath, charsetName, charsetBomMarked, -// checksum, fileAttributes, text, snippets. The Go side only reads SourcePath -// and Text; checksum / fileAttributes / each snippet's fields are consumed and -// discarded so the wire stays in lockstep. -func (r *GoReceiver) receivePlainText(pt *java.PlainText, q *ReceiveQueue) *java.PlainText { - idStr := receiveScalar[string](q, pt.Ident.String()) - if idStr != "" { - if parsed, err := uuid.Parse(idStr); err == nil { - pt.Ident = parsed - } - } - // markers: a nested object — consume its envelope via q.Receive, then read - // its sub-fields in the onChange, mirroring JavaReceiver.PreVisit. Calling - // receiveMarkersCodec directly (as receiveParseError does) skips the - // envelope message and desyncs the queue on any real, marker-bearing file. - if result := q.Receive(pt.Markers, func(v any) any { - return receiveMarkersCodec(q, v.(java.Markers)) - }); result != nil { - if mk, ok := result.(java.Markers); ok { - pt.Markers = mk - } - } - pt.SourcePath = receiveScalar[string](q, pt.SourcePath) - pt.CharsetName = receiveScalar[string](q, pt.CharsetName) - pt.CharsetBomMarked = receiveScalar[bool](q, pt.CharsetBomMarked) - // checksum — Checksum.rpcSend sends algorithm (string) + value (byte[]); - // nullable, so the onChange only runs when present (matches VisitCompilationUnit). - q.Receive(nil, func(v any) any { - receiveScalar[string](q, "") // algorithm - q.Receive(nil, nil) // value - return nil - }) - // fileAttributes — FileAttributes.rpcSend sends 7 sub-fields (3 timestamps, - // 3 bools, size); populated on files read from disk. The timestamps are - // java.time.ZonedDateTime leaves (see value_types factories). - q.Receive(nil, func(v any) any { - q.Receive(nil, nil) // creationTime - q.Receive(nil, nil) // lastModifiedTime - q.Receive(nil, nil) // lastAccessTime - q.Receive(nil, nil) // isReadable - q.Receive(nil, nil) // isWritable - q.Receive(nil, nil) // isExecutable - q.Receive(nil, nil) // size - return nil - }) - pt.Text = receiveScalar[string](q, pt.Text) - // snippets: a list of {id, markers, text}; empty for files. Consume each - // element's fields to keep the queue aligned if a recipe ever produced one. - q.ReceiveList(nil, func(v any) any { - q.Receive(nil, nil) // snippet id - q.Receive(nil, nil) // snippet markers - q.Receive(nil, nil) // snippet text - return v - }) - return pt -} - func (r *GoReceiver) VisitCompilationUnit(cu *golang.CompilationUnit, p any) java.J { q := p.(*ReceiveQueue) c := *cu // shallow copy to avoid mutating remoteObjects baseline diff --git a/rewrite-go/pkg/rpc/value_types.go b/rewrite-go/pkg/rpc/value_types.go index ebc3d6d2f72..244a9367e39 100644 --- a/rewrite-go/pkg/rpc/value_types.go +++ b/rewrite-go/pkg/rpc/value_types.go @@ -227,12 +227,8 @@ func init() { RegisterFactory("org.openrewrite.tree.ParseError", func() any { return &java.ParseError{Ident: uuid.New()} }) RegisterFactory("org.openrewrite.ParseExceptionResult", func() any { return java.ParseExceptionResult{} }) - // PlainText — received only (the CLI's Go build step backfills one for any - // .go file the parser doesn't return); GoModTidy reads its imports. - RegisterFactory("org.openrewrite.text.PlainText", func() any { return &java.PlainText{Ident: uuid.New()} }) - // java.time.* leaf values appear inside FileAttributes (creation / - // lastModified / lastAccess times) on PlainText files read from disk. We + // lastModified / lastAccess times) on any source file read from disk. We // discard FileAttributes, but the receiver still instantiates each leaf via // newObj before discarding its value — register benign factories so an // otherwise-unknown type doesn't panic mid-receive. diff --git a/rewrite-go/pkg/tree/java/plain_text.go b/rewrite-go/pkg/tree/java/plain_text.go deleted file mode 100644 index 623b8ee8f3d..00000000000 --- a/rewrite-go/pkg/tree/java/plain_text.go +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package java - -import "github.com/google/uuid" - -// PlainText mirrors org.openrewrite.text.PlainText — the LST fallback for a -// file no language parser claimed. The Go side only ever *receives* one (the -// Moderne CLI's Go build step backfills a PlainText for any `.go` file the -// parser doesn't return, e.g. a file excluded by the host build context), so -// this type carries just enough to read its source: a Go recipe that wants -// the imports of build-excluded files scans Text. It is never produced or -// sent by the Go side. -// -// Fields not needed Go-side (checksum, fileAttributes, snippets) are consumed -// and discarded by the receive codec rather than modeled here. -type PlainText struct { - Ident uuid.UUID - Markers Markers - SourcePath string - CharsetName string - CharsetBomMarked bool - Text string -} - -// PlainText flows through the same Tree-typed Visit pipeline as a SourceFile -// alternate. Like ParseError it isn't a J node; RPC receivers and visitors -// special-case it ahead of the J dispatch. -func (*PlainText) IsTree() {} diff --git a/rewrite-go/test/build_tags_test.go b/rewrite-go/test/build_tags_test.go index c080208aca3..cd4111e3fed 100644 --- a/rewrite-go/test/build_tags_test.go +++ b/rewrite-go/test/build_tags_test.go @@ -18,28 +18,25 @@ package test import ( "go/build" + "path/filepath" "sort" "testing" "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" - "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" - "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" ) -// parsedNames returns the file names included by ParsePackage for the -// given build context — the names of files that survived `//go:build` -// and filename-suffix constraint evaluation. These inputs are all -// well-formed, so every included file is expected to be a CompilationUnit. +// parsedNames returns the file names that are build-INCLUDED for the given +// build context — the ones that survive `//go:build` and filename-suffix +// constraint evaluation (i.e. would be type-checked with the package). Note +// ParsePackage now also emits build-EXCLUDED files as CompilationUnits so +// `go mod tidy` can see their imports; this helper exercises the constraint +// evaluation itself, which decides build inclusion. func parsedNames(t *testing.T, buildCtx build.Context, files []parser.FileInput) []string { t.Helper() - p := parser.NewGoParserWithBuildContext(buildCtx) out := make([]string, 0, len(files)) - for _, sf := range p.ParsePackage(files) { - switch v := sf.(type) { - case *golang.CompilationUnit: - out = append(out, v.SourcePath) - case *java.ParseError: - t.Fatalf("unexpected parse error for %s: %v", v.SourcePath, v.Cause()) + for _, sf := range files { + if parser.MatchBuildContext(buildCtx, filepath.Base(sf.Path), sf.Content) { + out = append(out, sf.Path) } } sort.Strings(out) diff --git a/rewrite-go/test/file_imports_test.go b/rewrite-go/test/file_imports_test.go deleted file mode 100644 index 5f606cf7ac8..00000000000 --- a/rewrite-go/test/file_imports_test.go +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package test - -import ( - "sort" - "testing" - - "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" -) - -// TestFileImports verifies imports-only extraction works regardless of build -// constraints (the file here is windows-gated) and handles aliased, blank, -// and grouped imports. -func TestFileImports(t *testing.T) { - src := "//go:build windows\n\n" + - "package sys\n\n" + - "import (\n" + - "\t\"fmt\"\n" + - "\t_ \"embed\"\n" + - "\talias \"golang.org/x/sys/windows\"\n" + - ")\n\n" + - "import \"strings\"\n" - - got := parser.FileImports(src) - sort.Strings(got) - want := []string{"embed", "fmt", "golang.org/x/sys/windows", "strings"} - if len(got) != len(want) { - t.Fatalf("got %v, want %v", got, want) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("got %v, want %v", got, want) - } - } -} - -// TestFileImportsMalformed returns nil (not a panic) for unparseable source. -func TestFileImportsMalformed(t *testing.T) { - if got := parser.FileImports("package @@@ broken"); got != nil { - t.Errorf("expected nil for malformed source, got %v", got) - } -} diff --git a/rewrite-go/test/parse_failure_test.go b/rewrite-go/test/parse_failure_test.go index 1dbc8754c04..3b5441f90aa 100644 --- a/rewrite-go/test/parse_failure_test.go +++ b/rewrite-go/test/parse_failure_test.go @@ -18,9 +18,11 @@ package test import ( "runtime" + "strings" "testing" "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" + "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" ) @@ -62,21 +64,39 @@ func TestParsePackageEmitsParseErrorForUnparseableFile(t *testing.T) { } } -// TestParsePackageOmitsBuildExcludedFiles verifies that a file excluded by -// the build context (`//go:build ignore`) is omitted entirely — it is not -// part of this build, so it must not surface as a ParseError (which would -// be a spurious failure) nor as a CompilationUnit. -func TestParsePackageOmitsBuildExcludedFiles(t *testing.T) { +// TestParsePackageEmitsBuildExcludedFiles verifies that a file excluded by the +// build context (a `//go:build windows` file on a non-Windows host) is still +// parsed and emitted as a CompilationUnit — `go mod tidy` unions imports across +// every platform, so a module imported only by a platform-gated file (cobra's +// mousetrap is the canonical case) must remain visible. The excluded file is a +// full, lossless CompilationUnit (it round-trips), just type-checked only with +// the build-included set. +func TestParsePackageEmitsBuildExcludedFiles(t *testing.T) { sfs := parser.NewGoParser().ParsePackage([]parser.FileInput{ - {Path: "main.go", Content: "package main\n\nfunc main() {}\n"}, - {Path: "ignored.go", Content: "//go:build ignore\n\npackage main\n"}, + {Path: "main.go", Content: "package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println() }\n"}, + {Path: "win.go", Content: "//go:build windows\n\npackage main\n\nimport _ \"example.com/winonly\"\n"}, }) - if len(sfs) != 1 { - t.Fatalf("expected only the build-included file, got %d source files", len(sfs)) + if len(sfs) != 2 { + t.Fatalf("expected both the included and the build-excluded file as CompilationUnits, got %d", len(sfs)) } - if _, ok := sfs[0].(*golang.CompilationUnit); !ok { - t.Fatalf("expected a CompilationUnit, got %T", sfs[0]) + var win *golang.CompilationUnit + for _, sf := range sfs { + cu, ok := sf.(*golang.CompilationUnit) + if !ok { + t.Fatalf("expected a CompilationUnit, got %T", sf) + } + if cu.SourcePath == "win.go" { + win = cu + } + } + if win == nil { + t.Fatal("the build-excluded win.go was not emitted as a CompilationUnit") + } + // Its windows-only import must be present (so the recipe can harvest it) and + // the file must round-trip losslessly. + if printed := printer.Print(win); !strings.Contains(printed, "example.com/winonly") { + t.Errorf("build-excluded file's import was lost; round-trip:\n%s", printed) } } From 58efe412616cb33f88eb97c939a2b2081c46650c Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Mon, 22 Jun 2026 17:36:06 -0700 Subject: [PATCH 10/19] Go: drop bespoke offline env vars; use GOPROXY=off like the toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proxyResolveEnabled gated network module resolution on two Moderne-specific environment variables — MODERNE_GO_OFFLINE (newly invented) and MODERNE_GO_PROXY_RESOLVE — which have no analog in how rewrite handles Maven or other ecosystems. rewrite attempts the network via the CLI HttpSender and degrades gracefully when it is unavailable; it does not expose per-ecosystem offline toggles. Follow that pattern: resolution is on by default and disabled the Go-native way with GOPROXY=off (the standard mechanism for air-gapped builds). When the proxy is unreachable for any other reason, resolution already falls back to the local cache and the existing require set. Removed both env vars and the isTruthy helper they needed. GOPROXY=off is honored as full-offline; a GOPROXY list like "https://corp,off" still enables the proxy. Verified: GOPROXY=off builds and runs cleanly (cobra degrades gracefully, no crash, requires preserved); default remains network-on. --- rewrite-go/cmd/rpc/main.go | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index f93b01e3637..1755cc992a3 100644 --- a/rewrite-go/cmd/rpc/main.go +++ b/rewrite-go/cmd/rpc/main.go @@ -89,7 +89,7 @@ type rpcError struct { // subtrees' data (whitespace) on the print transport. type server struct { localObjects map[string]any - remoteObjects map[string]any // last-synced state shared by both directions + remoteObjects map[string]any // last-synced state shared by both directions localRefs map[uintptr]int // SEND ref table (persistent) remoteRefs map[int]any // RECEIVE ref table (persistent) batchSize int @@ -879,8 +879,8 @@ func (s *server) fetchHTTP(url string) ([]byte, int, error) { // HttpSender) on a miss. Best-effort: on any failure the marker keeps whatever // was resolved and GraphComplete reflects partiality. // -// Proxy fetching is on by default (opt out with MODERNE_GO_OFFLINE or -// GOPROXY=off). Fetched modules are written through to the standard module +// Proxy fetching is on by default and disabled the Go-native way with +// GOPROXY=off. Fetched modules are written through to the standard module // cache, so the first parse warms it and every later parse/recipe run — across // projects on the machine — resolves the full graph offline. func (s *server) resolveModuleGraph(goModContent []byte, mrr *golang.GoResolutionResult) { @@ -909,29 +909,15 @@ func (s *server) moduleSource() modgraph.ModSource { return modgraph.TieredSource(sources...) } -// proxyResolveEnabled reports whether network module resolution is allowed. -// On by default; disabled for air-gapped/offline runs via MODERNE_GO_OFFLINE, -// GOPROXY=off, or an explicit MODERNE_GO_PROXY_RESOLVE=0/false/off. +// proxyResolveEnabled reports whether the GOPROXY tier should be added. Network +// module resolution is on by default — like rewrite's other ecosystems we +// attempt the network (via the CLI HttpSender) and degrade gracefully to the +// local cache and the existing require set when it is unavailable. It is +// disabled the Go-native way, GOPROXY=off, the standard mechanism for +// air-gapped builds. (A GOPROXY list such as "https://corp,off" still enables +// the proxy; only the bare value "off" means no network.) func proxyResolveEnabled() bool { - if isTruthy(os.Getenv("MODERNE_GO_OFFLINE")) { - return false - } - if strings.TrimSpace(os.Getenv("GOPROXY")) == "off" { - return false - } - if v, ok := os.LookupEnv("MODERNE_GO_PROXY_RESOLVE"); ok && !isTruthy(v) { - return false - } - return true -} - -func isTruthy(v string) bool { - switch strings.ToLower(strings.TrimSpace(v)) { - case "1", "true", "yes", "on": - return true - default: - return false - } + return strings.TrimSpace(os.Getenv("GOPROXY")) != "off" } func envOr(key, def string) string { From 49253f83689c9ca9e59cd4e830373685afd9407d Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Mon, 22 Jun 2026 18:12:12 -0700 Subject: [PATCH 11/19] Polish --- .../src/main/java/org/openrewrite/rpc/RewriteRpc.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java b/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java index 8a06df76eb7..0d88a66ac09 100644 --- a/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java +++ b/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java @@ -346,10 +346,7 @@ public void reset() { // from a GOPROXY at recipe time). private void setHttpSenderFrom(@Nullable Object p) { if (p instanceof ExecutionContext) { - HttpSender sender = HttpSenderExecutionContextView.view((ExecutionContext) p).getHttpSender(); - if (sender != null) { - this.httpSender = sender; - } + this.httpSender = HttpSenderExecutionContextView.view((ExecutionContext) p).getHttpSender(); } } From 1b0c2c7e06fb911495b83d433bdd796036c80dfd Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Tue, 23 Jun 2026 16:17:07 -0700 Subject: [PATCH 12/19] Go: Java module resolver, phase 1-2 (foundations + HttpSender ModSource) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of porting the Go dependency resolver to pure Java so the generic Http RPC method can eventually be removed from core RewriteRpc (HTTP moves entirely into the host, no peer-initiated fetch). Foundations (faithful ports, with tests): - GoSemver: golang.org/x/mod/semver Compare, the ordering MVS and the pruning version gate depend on. - ModulePath: module path/version escaping for the proxy URL and cache layout. - GoModFile: a light go.mod reader (module/go/require/replace) for the project and the many dependency go.mods. - GoImports: an imports-only Go source scanner (replaces go/parser ImportsOnly), comment/string aware. ModSource (the network layer, in Java): - ModSource interface; CacheSource ($GOMODCACHE read, extracted tree or cached zip); ProxySource (fetch via HttpSender + write-through to the standard cache layout, atomic writes); TieredSource; Zips (module-zip extraction/filtering). ProxySource calls HttpSender directly — no RPC for fetching. Not yet wired in: Resolve (pruned MVS), NeededModules/TidyRequireSet, and the RPC method that replaces the Go-side resolver. The existing Go resolver remains the active path until those land. All new code compiles at Java 8 and the modgraph unit tests pass. --- .../golang/internal/modgraph/CacheSource.java | 100 ++++++ .../golang/internal/modgraph/GoImports.java | 183 +++++++++++ .../golang/internal/modgraph/GoModFile.java | 200 ++++++++++++ .../golang/internal/modgraph/GoSemver.java | 288 ++++++++++++++++++ .../golang/internal/modgraph/ModSource.java | 44 +++ .../golang/internal/modgraph/ModulePath.java | 64 ++++ .../golang/internal/modgraph/ProxySource.java | 142 +++++++++ .../internal/modgraph/TieredSource.java | 52 ++++ .../golang/internal/modgraph/Zips.java | 91 ++++++ .../internal/modgraph/CacheSourceTest.java | 81 +++++ .../internal/modgraph/GoImportsTest.java | 93 ++++++ .../internal/modgraph/GoModFileTest.java | 103 +++++++ .../internal/modgraph/GoSemverTest.java | 103 +++++++ 13 files changed, 1544 insertions(+) create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/CacheSource.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoImports.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoModFile.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoSemver.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModSource.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModulePath.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ProxySource.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/TieredSource.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Zips.java create mode 100644 rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/CacheSourceTest.java create mode 100644 rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoImportsTest.java create mode 100644 rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoModFileTest.java create mode 100644 rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoSemverTest.java diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/CacheSource.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/CacheSource.java new file mode 100644 index 00000000000..605a9f0e1ee --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/CacheSource.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Reads module metadata from the local Go module cache (the value of {@code go + * env GOMODCACHE}). go.mod files live under {@code cache/download//@v/}; a + * package's sources come from the extracted module tree when present, otherwise + * from the cached {@code .zip} that the write-through proxy persists — so a clean + * checkout can serve dependency sources without any {@code go} extraction step. + */ +public final class CacheSource implements ModSource { + + private final Path root; // GOMODCACHE; extracted module dirs live directly under it + private final Path download; // GOMODCACHE/cache/download + + public CacheSource(String gomodcache) { + this.root = Paths.get(gomodcache); + this.download = root.resolve("cache").resolve("download"); + } + + @Override + public byte @Nullable [] goMod(String path, String version) { + Path p = download.resolve(ModulePath.escapePath(path)).resolve("@v") + .resolve(ModulePath.escapeVersion(version) + ".mod"); + try { + return Files.readAllBytes(p); + } catch (IOException e) { + return null; + } + } + + @Override + public @Nullable Map packageGoFiles(String modPath, String version, String importPath) { + String ep = ModulePath.escapePath(modPath); + String ev = ModulePath.escapeVersion(version); + + // Preferred: the extracted module tree ($GOMODCACHE/@/). + String rel = importPath.startsWith(modPath) ? importPath.substring(modPath.length()) : importPath; + if (rel.startsWith("/")) { + rel = rel.substring(1); + } + Path dir = root.resolve(ep + "@" + ev); + for (String seg : rel.split("/")) { + if (!seg.isEmpty()) { + dir = dir.resolve(seg); + } + } + if (Files.isDirectory(dir)) { + Map files = new HashMap<>(); + try (Stream list = Files.list(dir)) { + list.filter(f -> f.getFileName().toString().endsWith(".go") && Files.isRegularFile(f)) + .forEach(f -> { + try { + files.put(f.getFileName().toString(), Files.readAllBytes(f)); + } catch (IOException ignored) { + } + }); + } catch (IOException ignored) { + } + if (!files.isEmpty()) { + return files; + } + } + + // Fallback: the cached download zip. + Path zip = download.resolve(ep).resolve("@v").resolve(ev + ".zip"); + try { + Map entries = Zips.goFiles(Files.readAllBytes(zip)); + if (entries != null) { + return Zips.packageFiles(entries, modPath, version, importPath); + } + } catch (IOException ignored) { + } + return null; + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoImports.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoImports.java new file mode 100644 index 00000000000..0b26b425393 --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoImports.java @@ -0,0 +1,183 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import java.util.ArrayList; +import java.util.List; + +/** + * Extracts the import paths from a Go source file — the Java equivalent of the + * resolver's previous use of {@code go/parser} in imports-only mode. Imports in + * Go appear at the top of a file, after the {@code package} clause and before + * any other top-level declaration, so the scanner reads the package clause, then + * {@code import} declarations (single or {@code ( ... )} block, with optional + * {@code _}/{@code .}/alias), and stops at the first non-import declaration. + * Comments and string/raw-string literals are skipped so tokens inside them are + * never mistaken for imports. + */ +public final class GoImports { + + private final String s; + private int i; + + private GoImports(String src) { + this.s = src; + } + + public static List parse(String src) { + return new GoImports(src).scan(); + } + + private List scan() { + List out = new ArrayList<>(); + // Skip the package clause: 'package '. + skipSpaceAndComments(); + if (matchWord("package")) { + skipSpaceAndComments(); + readIdent(); + } + while (true) { + skipSpaceAndComments(); + if (atEnd() || !matchWord("import")) { + break; // first non-import top-level declaration ends the import section + } + skipSpaceAndComments(); + if (!atEnd() && peek() == '(') { + i++; // consume '(' + while (true) { + skipSpaceAndComments(); + if (atEnd() || peek() == ')') { + if (!atEnd()) { + i++; + } + break; + } + readImportSpec(out); + } + } else { + readImportSpec(out); + } + } + return out; + } + + // An import spec is an optional name (_, ., or identifier) followed by the + // quoted import path. + private void readImportSpec(List out) { + if (atEnd()) { + return; + } + char c = peek(); + if (c == '"') { + out.add(readString()); + return; + } + if (c == '.' || c == '_' || isIdentStart(c)) { + if (c == '.') { + i++; + } else { + readIdent(); + } + skipSpaceAndComments(); + if (!atEnd() && peek() == '"') { + out.add(readString()); + } + return; + } + i++; // unexpected character; skip defensively + } + + private void skipSpaceAndComments() { + while (!atEnd()) { + char c = peek(); + if (Character.isWhitespace(c)) { + i++; + } else if (c == '/' && i + 1 < s.length() && s.charAt(i + 1) == '/') { + while (!atEnd() && peek() != '\n') { + i++; + } + } else if (c == '/' && i + 1 < s.length() && s.charAt(i + 1) == '*') { + i += 2; + while (i + 1 < s.length() && !(s.charAt(i) == '*' && s.charAt(i + 1) == '/')) { + i++; + } + i = Math.min(i + 2, s.length()); + } else { + return; + } + } + } + + // Reads a double-quoted Go string literal (peek == '"'), returning its + // unescaped content. Import paths only ever use the basic escapes. + private String readString() { + i++; // consume opening quote + StringBuilder sb = new StringBuilder(); + while (!atEnd()) { + char c = s.charAt(i++); + if (c == '\\' && !atEnd()) { + char n = s.charAt(i++); + switch (n) { + case 'n': sb.append('\n'); break; + case 't': sb.append('\t'); break; + case '"': sb.append('"'); break; + case '\\': sb.append('\\'); break; + default: sb.append(n); break; + } + } else if (c == '"') { + break; + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private void readIdent() { + while (!atEnd() && isIdentPart(peek())) { + i++; + } + } + + // Consumes the word if it matches exactly (and is not part of a longer + // identifier), returning whether it matched. + private boolean matchWord(String w) { + if (s.startsWith(w, i)) { + int end = i + w.length(); + if (end >= s.length() || !isIdentPart(s.charAt(end))) { + i = end; + return true; + } + } + return false; + } + + private char peek() { + return s.charAt(i); + } + + private boolean atEnd() { + return i >= s.length(); + } + + private static boolean isIdentStart(char c) { + return Character.isLetter(c) || c == '_'; + } + + private static boolean isIdentPart(char c) { + return Character.isLetterOrDigit(c) || c == '_'; + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoModFile.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoModFile.java new file mode 100644 index 00000000000..fd4679fe1d9 --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoModFile.java @@ -0,0 +1,200 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * A small, allocation-light go.mod reader for the resolver. It extracts only + * what Minimal Version Selection needs — the module path, the {@code go} + * directive, the {@code require} set (with {@code // indirect} flags), and the + * main module's {@code replace} directives — from both the project go.mod and + * the (many) dependency go.mod files fetched from the proxy. It is line-oriented + * and tolerant: anything it does not recognize is skipped, matching how the Go + * toolchain's modfile loader treats unknown directives for resolution purposes. + */ +public final class GoModFile { + + @Nullable String modulePath; + @Nullable String goVersion; + final List requires = new ArrayList<>(); + final List replaces = new ArrayList<>(); + + public @Nullable String modulePath() { + return modulePath; + } + + public @Nullable String goVersion() { + return goVersion; + } + + public List requires() { + return requires; + } + + public List replaces() { + return replaces; + } + + public static final class Require { + public final String path; + public final String version; + public final boolean indirect; + + Require(String path, String version, boolean indirect) { + this.path = path; + this.version = version; + this.indirect = indirect; + } + } + + public static final class Replace { + public final String oldPath; + public final @Nullable String oldVersion; + public final String newPath; + public final @Nullable String newVersion; + + Replace(String oldPath, @Nullable String oldVersion, String newPath, @Nullable String newVersion) { + this.oldPath = oldPath; + this.oldVersion = oldVersion; + this.newPath = newPath; + this.newVersion = newVersion; + } + } + + public static GoModFile parse(String content) { + GoModFile mf = new GoModFile(); + String block = null; // "require" or "replace" while inside a ( ... ) block + for (String raw : content.split("\n", -1)) { + String line = raw; + boolean indirect = stripComment(line).indirect; + line = stripComment(line).text.trim(); + if (line.isEmpty()) { + continue; + } + if (block != null) { + if (line.equals(")")) { + block = null; + continue; + } + if (block.equals("require")) { + mf.addRequire(tokens(line), indirect); + } else if (block.equals("replace")) { + mf.addReplace(tokens(line)); + } + continue; + } + List t = tokens(line); + if (t.isEmpty()) { + continue; + } + switch (t.get(0)) { + case "module": + if (t.size() > 1) { + mf.modulePath = unquote(t.get(1)); + } + break; + case "go": + if (t.size() > 1) { + mf.goVersion = t.get(1); + } + break; + case "require": + if (t.size() == 2 && t.get(1).equals("(")) { + block = "require"; + } else { + mf.addRequire(t.subList(1, t.size()), indirect); + } + break; + case "replace": + if (t.size() == 2 && t.get(1).equals("(")) { + block = "replace"; + } else { + mf.addReplace(t.subList(1, t.size())); + } + break; + default: + // exclude / retract / toolchain etc. — not needed for resolution + break; + } + } + return mf; + } + + private void addRequire(List t, boolean indirect) { + if (t.size() >= 2) { + requires.add(new Require(unquote(t.get(0)), unquote(t.get(1)), indirect)); + } + } + + // Forms: "old => new ver" | "old => new" | "old ver => new ver" | "old ver => new" + private void addReplace(List t) { + int arrow = t.indexOf("=>"); + if (arrow < 0) { + return; + } + List lhs = t.subList(0, arrow); + List rhs = t.subList(arrow + 1, t.size()); + if (lhs.isEmpty() || rhs.isEmpty()) { + return; + } + String oldPath = unquote(lhs.get(0)); + String oldVersion = lhs.size() > 1 ? unquote(lhs.get(1)) : null; + String newPath = unquote(rhs.get(0)); + String newVersion = rhs.size() > 1 ? unquote(rhs.get(1)) : null; + replaces.add(new Replace(oldPath, oldVersion, newPath, newVersion)); + } + + private static final class Stripped { + final String text; + final boolean indirect; + + Stripped(String text, boolean indirect) { + this.text = text; + this.indirect = indirect; + } + } + + private static Stripped stripComment(String line) { + int c = line.indexOf("//"); + if (c < 0) { + return new Stripped(line, false); + } + boolean indirect = line.substring(c).replace("//", "").trim().equals("indirect"); + return new Stripped(line.substring(0, c), indirect); + } + + private static List tokens(String line) { + List out = new ArrayList<>(); + for (String tok : line.trim().split("\\s+")) { + if (!tok.isEmpty()) { + out.add(tok); + } + } + return out; + } + + private static String unquote(String s) { + if (s.length() >= 2 && (s.charAt(0) == '"' || s.charAt(0) == '`') && + s.charAt(s.length() - 1) == s.charAt(0)) { + return s.substring(1, s.length() - 1); + } + return s; + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoSemver.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoSemver.java new file mode 100644 index 00000000000..cd91c3d4066 --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoSemver.java @@ -0,0 +1,288 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +/** + * A faithful port of {@code golang.org/x/mod/semver}'s {@code Compare}, the + * version ordering the Go toolchain uses for Minimal Version Selection. Go + * semantic versions are {@code vMAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]}; the + * leading {@code v} is required, build metadata is ignored, and a version that + * does not parse sorts before every version that does. + *

+ * Kept structurally parallel to the upstream Go source so divergence is easy to + * audit — the Go resolver this replaces depended on {@code semver.Compare} for + * MVS and the pruning version gate, where an off-by-one ordering silently + * changes the selected build list. + */ +public final class GoSemver { + + private GoSemver() { + } + + /** + * Compares two Go semantic versions, returning -1, 0, or +1. Invalid + * versions sort below valid ones (two invalid versions compare equal). + */ + public static int compare(String v, String w) { + Parsed pv = parse(v); + Parsed pw = parse(w); + if (pv == null && pw == null) { + return 0; + } + if (pv == null) { + return -1; + } + if (pw == null) { + return +1; + } + int c = compareInt(pv.major, pw.major); + if (c != 0) { + return c; + } + c = compareInt(pv.minor, pw.minor); + if (c != 0) { + return c; + } + c = compareInt(pv.patch, pw.patch); + if (c != 0) { + return c; + } + return comparePrerelease(pv.prerelease, pw.prerelease); + } + + private static final class Parsed { + String major = ""; + String minor = ""; + String patch = ""; + String prerelease = ""; + @SuppressWarnings("unused") + String build = ""; + } + + /** A holder for the {@code (token, rest, ok)} tuples the Go scanner returns. */ + private static final class Scan { + final String token; + final String rest; + + Scan(String token, String rest) { + this.token = token; + this.rest = rest; + } + } + + private static Parsed parse(String v) { + if (v == null || v.isEmpty() || v.charAt(0) != 'v') { + return null; + } + Parsed p = new Parsed(); + Scan s = parseInt(v.substring(1)); + if (s == null) { + return null; + } + p.major = s.token; + v = s.rest; + if (v.isEmpty()) { + p.minor = "0"; + p.patch = "0"; + return p; + } + if (v.charAt(0) != '.') { + return null; + } + s = parseInt(v.substring(1)); + if (s == null) { + return null; + } + p.minor = s.token; + v = s.rest; + if (v.isEmpty()) { + p.patch = "0"; + return p; + } + if (v.charAt(0) != '.') { + return null; + } + s = parseInt(v.substring(1)); + if (s == null) { + return null; + } + p.patch = s.token; + v = s.rest; + if (!v.isEmpty() && v.charAt(0) == '-') { + s = parsePrerelease(v); + if (s == null) { + return null; + } + p.prerelease = s.token; + v = s.rest; + } + if (!v.isEmpty() && v.charAt(0) == '+') { + s = parseBuild(v); + if (s == null) { + return null; + } + p.build = s.token; + v = s.rest; + } + if (!v.isEmpty()) { + return null; + } + return p; + } + + private static Scan parseInt(String v) { + if (v.isEmpty()) { + return null; + } + if (v.charAt(0) < '0' || '9' < v.charAt(0)) { + return null; + } + int i = 1; + while (i < v.length() && '0' <= v.charAt(i) && v.charAt(i) <= '9') { + i++; + } + if (v.charAt(0) == '0' && i != 1) { + return null; + } + return new Scan(v.substring(0, i), v.substring(i)); + } + + private static Scan parsePrerelease(String v) { + if (v.isEmpty() || v.charAt(0) != '-') { + return null; + } + int i = 1; + int start = 1; + while (i < v.length() && v.charAt(i) != '+') { + if (!isIdentChar(v.charAt(i)) && v.charAt(i) != '.') { + return null; + } + if (v.charAt(i) == '.') { + if (start == i || isBadNum(v.substring(start, i))) { + return null; + } + start = i + 1; + } + i++; + } + if (start == i || isBadNum(v.substring(start, i))) { + return null; + } + return new Scan(v.substring(0, i), v.substring(i)); + } + + private static Scan parseBuild(String v) { + if (v.isEmpty() || v.charAt(0) != '+') { + return null; + } + int i = 1; + int start = 1; + while (i < v.length()) { + if (!isIdentChar(v.charAt(i)) && v.charAt(i) != '.') { + return null; + } + if (v.charAt(i) == '.') { + if (start == i) { + return null; + } + start = i + 1; + } + i++; + } + if (start == i) { + return null; + } + return new Scan(v.substring(0, i), v.substring(i)); + } + + private static int compareInt(String x, String y) { + if (x.equals(y)) { + return 0; + } + if (x.length() < y.length()) { + return -1; + } + if (x.length() > y.length()) { + return +1; + } + return x.compareTo(y) < 0 ? -1 : +1; + } + + private static int comparePrerelease(String x, String y) { + if (x.equals(y)) { + return 0; + } + if (x.isEmpty()) { + return +1; + } + if (y.isEmpty()) { + return -1; + } + while (!x.isEmpty() && !y.isEmpty()) { + x = x.substring(1); // skip - or . + y = y.substring(1); + String dx = nextIdent(x); + x = x.substring(dx.length()); + String dy = nextIdent(y); + y = y.substring(dy.length()); + if (!dx.equals(dy)) { + boolean ix = isNum(dx); + boolean iy = isNum(dy); + if (ix != iy) { + return ix ? -1 : +1; + } + if (ix) { + if (dx.length() < dy.length()) { + return -1; + } + if (dx.length() > dy.length()) { + return +1; + } + } + return dx.compareTo(dy) < 0 ? -1 : +1; + } + } + return x.isEmpty() ? -1 : +1; + } + + private static String nextIdent(String x) { + int i = 0; + while (i < x.length() && x.charAt(i) != '.') { + i++; + } + return x.substring(0, i); + } + + private static boolean isNum(String v) { + int i = 0; + while (i < v.length() && '0' <= v.charAt(i) && v.charAt(i) <= '9') { + i++; + } + return i == v.length(); + } + + private static boolean isIdentChar(char c) { + return 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '-'; + } + + private static boolean isBadNum(String v) { + int i = 0; + while (i < v.length() && '0' <= v.charAt(i) && v.charAt(i) <= '9') { + i++; + } + return i == v.length() && i > 1 && v.charAt(0) == '0'; + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModSource.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModSource.java new file mode 100644 index 00000000000..bf68617770d --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModSource.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** + * Supplies module metadata to the resolver, abstracting WHERE the bytes come + * from (the local {@code $GOMODCACHE}, a GOPROXY, or a tiered combination) so + * the resolution algorithm is identical regardless. This is the Java home of + * what used to be the Go {@code modgraph.ModSource}; the proxy implementation + * performs HTTP directly through the CLI's configured {@link + * org.openrewrite.ipc.http.HttpSender}, so no peer-initiated fetch crosses the + * RPC boundary. + */ +public interface ModSource { + + /** The go.mod bytes for {@code path@version}, or {@code null} if not found. */ + byte @Nullable [] goMod(String path, String version); + + /** + * The {@code .go} source files of the package at {@code importPath} within + * {@code modPath@version}, keyed by base filename (tests included), or + * {@code null} if the package could not be located. This gives the + * package-import walk the dependency sources it needs without any Go tooling + * — the proxy implementation downloads and extracts the module zip on demand. + */ + @Nullable Map packageGoFiles(String modPath, String version, String importPath); +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModulePath.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModulePath.java new file mode 100644 index 00000000000..8762b4eb01c --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModulePath.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +/** + * Module path/version escaping for the GOPROXY URL scheme and the on-disk module + * cache, ported from {@code golang.org/x/mod/module}. To keep case-insensitive + * filesystems and URLs unambiguous, each uppercase ASCII letter is replaced with + * {@code !} followed by its lowercase form (e.g. {@code github.com/Azure} -> + * {@code github.com/!azure}). Inputs here come from parsed go.mod / build-list + * data, so they are already well-formed; only the escaping transform is needed. + */ +public final class ModulePath { + + private ModulePath() { + } + + /** Escapes a module path for use in {@code //@v/...}. */ + public static String escapePath(String path) { + return escape(path); + } + + /** Escapes a module version for use in {@code .../@v/.mod}. */ + public static String escapeVersion(String version) { + return escape(version); + } + + private static String escape(String s) { + boolean haveUpper = false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if ('A' <= c && c <= 'Z') { + haveUpper = true; + break; + } + } + if (!haveUpper) { + return s; + } + StringBuilder buf = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if ('A' <= c && c <= 'Z') { + buf.append('!').append((char) (c + ('a' - 'A'))); + } else { + buf.append(c); + } + } + return buf.toString(); + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ProxySource.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ProxySource.java new file mode 100644 index 00000000000..7f674becc79 --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ProxySource.java @@ -0,0 +1,142 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.jspecify.annotations.Nullable; +import org.openrewrite.ipc.http.HttpSender; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Fetches module metadata from one or more GOPROXY bases over the CLI's + * {@link HttpSender} — proxy, auth, and TLS are honored, and crucially the HTTP + * stays entirely inside the host process (no peer-initiated fetch crosses the + * RPC boundary). When a write-through cache directory is configured, fetched + * {@code .mod}/{@code .zip} are persisted into the standard + * {@code $GOMODCACHE/cache/download} layout so later lookups (and the real + * {@code go} toolchain) serve them offline. Writes are atomic (temp + move). + */ +public final class ProxySource implements ModSource { + + private final List bases; + private final HttpSender http; + private final @Nullable Path cacheDir; // GOMODCACHE/cache/download, or null for no write-through + private final Map> zips = new HashMap<>(); + + public ProxySource(String goproxy, HttpSender http) { + this(goproxy, http, null); + } + + /** @param gomodcache enables write-through into {@code /cache/download}; null disables it. */ + public ProxySource(String goproxy, HttpSender http, @Nullable String gomodcache) { + this.http = http; + this.cacheDir = gomodcache == null ? null : Paths.get(gomodcache).resolve("cache").resolve("download"); + this.bases = parseProxyList(goproxy); + } + + private static List parseProxyList(String goproxy) { + List out = new ArrayList<>(); + if (goproxy != null) { + for (String p : goproxy.split("[,|]")) { + p = p.trim(); + if (p.isEmpty() || p.equals("off") || p.equals("direct") || p.equals("none")) { + continue; + } + out.add(p.replaceAll("/+$", "")); + } + } + if (out.isEmpty()) { + out.add("https://proxy.golang.org"); + } + return out; + } + + @Override + public byte @Nullable [] goMod(String path, String version) { + String ep = ModulePath.escapePath(path); + String ev = ModulePath.escapeVersion(version); + byte[] body = fetch("/" + ep + "/@v/" + ev + ".mod"); + if (body != null) { + persist(ep, ev, ".mod", body); + } + return body; + } + + @Override + public @Nullable Map packageGoFiles(String modPath, String version, String importPath) { + Map entries = moduleZip(modPath, version); + if (entries == null) { + return null; + } + return Zips.packageFiles(entries, modPath, version, importPath); + } + + private @Nullable Map moduleZip(String modPath, String version) { + String key = modPath + "@" + version; + if (zips.containsKey(key)) { + return zips.get(key); + } + String ep = ModulePath.escapePath(modPath); + String ev = ModulePath.escapeVersion(version); + byte[] raw = fetch("/" + ep + "/@v/" + ev + ".zip"); + Map entries = raw == null ? null : Zips.goFiles(raw); + if (raw != null && entries != null) { + persist(ep, ev, ".zip", raw); // write through only a well-formed zip + } + zips.put(key, entries); // memoize (null records a failed download) + return entries; + } + + private byte @Nullable [] fetch(String suffix) { + for (String base : bases) { + try (HttpSender.Response resp = http.send(http.get(base + suffix).build())) { + if (resp.isSuccessful()) { + byte[] body = resp.getBodyAsBytes(); + if (body != null && body.length > 0) { + return body; + } + } + } catch (Exception ignored) { + // try the next base + } + } + return null; + } + + private void persist(String ep, String ev, String suffix, byte[] content) { + if (cacheDir == null) { + return; + } + Path dir = cacheDir.resolve(ep).resolve("@v"); + Path target = dir.resolve(ev + suffix); + try { + Files.createDirectories(dir); + Path tmp = Files.createTempFile(dir, ".tmp-", suffix); + Files.write(tmp, content); + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException ignored) { + // best-effort: a cache write failure never affects resolution + } + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/TieredSource.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/TieredSource.java new file mode 100644 index 00000000000..8e8e4a3f89e --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/TieredSource.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** Tries each source in order, returning the first hit — local cache, then proxy. */ +public final class TieredSource implements ModSource { + + private final ModSource[] sources; + + public TieredSource(ModSource... sources) { + this.sources = sources; + } + + @Override + public byte @Nullable [] goMod(String path, String version) { + for (ModSource s : sources) { + byte[] b = s.goMod(path, version); + if (b != null) { + return b; + } + } + return null; + } + + @Override + public @Nullable Map packageGoFiles(String modPath, String version, String importPath) { + for (ModSource s : sources) { + Map files = s.packageGoFiles(modPath, version, importPath); + if (files != null) { + return files; + } + } + return null; + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Zips.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Zips.java new file mode 100644 index 00000000000..2d50a63dda9 --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Zips.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Module-zip helpers. A Go module zip stores every file under + * {@code @/...}; the resolver only needs the {@code .go} files + * (tests included), keyed by their full entry path, to walk the package import + * graph. + */ +final class Zips { + + private Zips() { + } + + /** Extracts every {@code .go} file from a module zip, keyed by entry path. */ + static @Nullable Map goFiles(byte[] raw) { + Map out = new HashMap<>(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(raw))) { + ZipEntry e; + byte[] buf = new byte[8192]; + while ((e = zis.getNextEntry()) != null) { + if (e.isDirectory() || !e.getName().endsWith(".go")) { + continue; + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + int n; + while ((n = zis.read(buf)) > 0) { + bos.write(buf, 0, n); + } + out.put(e.getName(), bos.toByteArray()); + } + } catch (IOException ex) { + return null; + } + return out; + } + + /** + * Filters extracted zip entries to the {@code .go} files of exactly one + * package directory ({@code importPath} within {@code modPath@version}) — + * the package itself, not deeper sub-packages. + */ + static @Nullable Map packageFiles(Map entries, String modPath, + String version, String importPath) { + String rel = importPath.startsWith(modPath) ? importPath.substring(modPath.length()) : importPath; + if (rel.startsWith("/")) { + rel = rel.substring(1); + } + String prefix = modPath + "@" + version + "/"; + if (!rel.isEmpty()) { + prefix += rel + "/"; + } + Map files = new HashMap<>(); + for (Map.Entry en : entries.entrySet()) { + String name = en.getKey(); + if (!name.startsWith(prefix)) { + continue; + } + String tail = name.substring(prefix.length()); + if (tail.indexOf('/') >= 0 || !tail.endsWith(".go")) { + continue; // a deeper sub-package or non-go file + } + files.put(tail, en.getValue()); + } + return files.isEmpty() ? null : files; + } +} diff --git a/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/CacheSourceTest.java b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/CacheSourceTest.java new file mode 100644 index 00000000000..bc63c839306 --- /dev/null +++ b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/CacheSourceTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +class CacheSourceTest { + + @Test + void readsGoModAndPackageFilesFromTheStandardCacheLayout(@TempDir Path gomodcache) throws Exception { + // Lay out cache/download//@v/.{mod,zip} as `go mod download` would. + String mod = "github.com/Example/Dep"; // uppercase -> escaped on disk + String ver = "v1.2.3"; + String esc = "github.com/!example/!dep"; + Path dl = gomodcache.resolve("cache/download").resolve(esc).resolve("@v"); + Files.createDirectories(dl); + Files.writeString(dl.resolve(ver + ".mod"), + "module github.com/Example/Dep\n\ngo 1.21\n"); + + // A module zip with a package `pkg` importing a unique module. + byte[] zip = zip(Map.of( + mod + "@" + ver + "/pkg/util.go", "package util\n\nimport _ \"example.com/other\"\n", + mod + "@" + ver + "/go.mod", "module github.com/Example/Dep\n" + )); + Files.write(dl.resolve(ver + ".zip"), zip); + + CacheSource cache = new CacheSource(gomodcache.toString()); + + byte[] goMod = cache.goMod(mod, ver); + assertThat(goMod).isNotNull(); + assertThat(new String(goMod, StandardCharsets.UTF_8)).contains("module github.com/Example/Dep"); + + Map files = cache.packageGoFiles(mod, ver, mod + "/pkg"); + assertThat(files).isNotNull().containsKey("util.go"); + assertThat(GoImports.parse(new String(files.get("util.go"), StandardCharsets.UTF_8))) + .containsExactly("example.com/other"); + } + + @Test + void missingModuleReturnsNull(@TempDir Path gomodcache) { + CacheSource cache = new CacheSource(gomodcache.toString()); + assertThat(cache.goMod("github.com/absent/mod", "v1.0.0")).isNull(); + assertThat(cache.packageGoFiles("github.com/absent/mod", "v1.0.0", "github.com/absent/mod")).isNull(); + } + + private static byte[] zip(Map entries) throws Exception { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(bos)) { + for (Map.Entry e : entries.entrySet()) { + zos.putNextEntry(new ZipEntry(e.getKey())); + zos.write(e.getValue().getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + return bos.toByteArray(); + } +} diff --git a/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoImportsTest.java b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoImportsTest.java new file mode 100644 index 00000000000..4d583d36048 --- /dev/null +++ b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoImportsTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GoImportsTest { + + @Test + void singleImport() { + assertThat(GoImports.parse("package main\n\nimport \"fmt\"\n\nfunc main() {}\n")) + .containsExactly("fmt"); + } + + @Test + void blockImportsWithAliasBlankAndDot() { + String src = + "package p\n" + + "\n" + + "import (\n" + + "\t\"fmt\"\n" + + "\t_ \"github.com/lib/pq\"\n" + + "\tm \"github.com/spf13/cobra\"\n" + + "\t. \"errors\"\n" + + ")\n" + + "\n" + + "func F() {}\n"; + assertThat(GoImports.parse(src)) + .containsExactlyInAnyOrder("fmt", "github.com/lib/pq", "github.com/spf13/cobra", "errors"); + } + + @Test + void multipleImportDeclarations() { + String src = + "package p\n" + + "import \"a\"\n" + + "import (\n\t\"b\"\n\t\"c\"\n)\n" + + "func g() {}\n"; + assertThat(GoImports.parse(src)).containsExactlyInAnyOrder("a", "b", "c"); + } + + @Test + void ignoresCommentsAndTokensInCodeBelow() { + // The word `import` and quoted strings appear in comments and in code + // after the import section; none must be treated as imports. + String src = + "// import \"not/an/import\"\n" + + "package p\n" + + "\n" + + "/* import \"also/not\" */\n" + + "import (\n" + + "\t\"real/dep\" // trailing comment\n" + + ")\n" + + "\n" + + "func main() {\n" + + "\ts := \"import \\\"fake\\\"\"\n" + + "\t_ = s\n" + + "}\n"; + assertThat(GoImports.parse(src)).containsExactly("real/dep"); + } + + @Test + void buildConstrainedFileStillYieldsImports() { + // A //go:build windows file is parsed imports-only the same way. + String src = + "//go:build windows\n" + + "\n" + + "package p\n" + + "\n" + + "import _ \"github.com/inconshreveable/mousetrap\"\n"; + assertThat(GoImports.parse(src)).containsExactly("github.com/inconshreveable/mousetrap"); + } + + @Test + void noImports() { + assertThat(GoImports.parse("package p\n\nfunc main() {}\n")).isEmpty(); + } +} diff --git a/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoModFileTest.java b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoModFileTest.java new file mode 100644 index 00000000000..a825110843f --- /dev/null +++ b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoModFileTest.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GoModFileTest { + + @Test + void parsesModuleGoAndBothRequireForms() { + GoModFile mf = GoModFile.parse( + "module github.com/example/Project\n" + + "\n" + + "go 1.21\n" + + "\n" + + "require github.com/spf13/cobra v1.8.0\n" + + "\n" + + "require (\n" + + "\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n" + + "\tgopkg.in/yaml.v3 v3.0.1 // indirect\n" + + "\tgithub.com/stretchr/testify v1.9.0\n" + + ")\n" + ); + + assertThat(mf.modulePath()).isEqualTo("github.com/example/Project"); + assertThat(mf.goVersion()).isEqualTo("1.21"); + assertThat(mf.requires()).hasSize(4); + + // single require, direct + assertThat(mf.requires()).anySatisfy(r -> { + assertThat(r.path).isEqualTo("github.com/spf13/cobra"); + assertThat(r.version).isEqualTo("v1.8.0"); + assertThat(r.indirect).isFalse(); + }); + // block require, indirect flag honored + assertThat(mf.requires()).anySatisfy(r -> { + assertThat(r.path).isEqualTo("github.com/davecgh/go-spew"); + assertThat(r.indirect).isTrue(); + }); + // block require, direct (no comment) + assertThat(mf.requires()).anySatisfy(r -> { + assertThat(r.path).isEqualTo("github.com/stretchr/testify"); + assertThat(r.indirect).isFalse(); + }); + } + + @Test + void parsesReplaceFormsSingleAndBlock() { + GoModFile mf = GoModFile.parse( + "module m\n\n" + + "replace github.com/old/pkg => github.com/new/pkg v1.2.3\n" + + "replace (\n" + + "\tgithub.com/a v1.0.0 => github.com/a v1.0.1\n" + + "\tgithub.com/local => ../local/path\n" + + ")\n" + ); + + assertThat(mf.replaces()).hasSize(3); + assertThat(mf.replaces()).anySatisfy(r -> { + assertThat(r.oldPath).isEqualTo("github.com/old/pkg"); + assertThat(r.oldVersion).isNull(); + assertThat(r.newPath).isEqualTo("github.com/new/pkg"); + assertThat(r.newVersion).isEqualTo("v1.2.3"); + }); + assertThat(mf.replaces()).anySatisfy(r -> { + assertThat(r.oldPath).isEqualTo("github.com/a"); + assertThat(r.oldVersion).isEqualTo("v1.0.0"); + assertThat(r.newVersion).isEqualTo("v1.0.1"); + }); + // local filesystem replace: no new version + assertThat(mf.replaces()).anySatisfy(r -> { + assertThat(r.oldPath).isEqualTo("github.com/local"); + assertThat(r.newPath).isEqualTo("../local/path"); + assertThat(r.newVersion).isNull(); + }); + } + + @Test + void escapesUppercaseForProxyAndCacheLayout() { + assertThat(ModulePath.escapePath("github.com/Azure/azure-sdk-for-go")) + .isEqualTo("github.com/!azure/azure-sdk-for-go"); + assertThat(ModulePath.escapePath("github.com/spf13/cobra")) + .isEqualTo("github.com/spf13/cobra"); // no uppercase, unchanged + assertThat(ModulePath.escapeVersion("v1.2.3")).isEqualTo("v1.2.3"); + assertThat(ModulePath.escapeVersion("v0.0.0-20210101000000-ABCDEF")) + .isEqualTo("v0.0.0-20210101000000-!a!b!c!d!e!f"); + } +} diff --git a/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoSemverTest.java b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoSemverTest.java new file mode 100644 index 00000000000..3e851292319 --- /dev/null +++ b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoSemverTest.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class GoSemverTest { + + private static int sign(int n) { + return Integer.compare(n, 0); + } + + @Test + void totalOrderingMatchesGo() { + // A strictly-increasing chain. Go's semver.Compare must order it exactly + // this way; it includes the canonical pre-release precedence example from + // the SemVer spec plus build metadata, +incompatible, and a pseudo-version. + List ascending = List.of( + "", // invalid sorts below every valid version + "v0.0.0-20210101000000-abcdef012345", // pseudo-version (pre-release) + "v0.0.0", + "v0.0.1", + "v0.1.0", + "v1.0.0-alpha", + "v1.0.0-alpha.1", + "v1.0.0-alpha.beta", + "v1.0.0-beta", + "v1.0.0-beta.2", + "v1.0.0-beta.11", + "v1.0.0-rc.1", + "v1.0.0", + "v1.0.1", + "v1.2.0", + "v1.11.0", // 11 > 2 numerically, not lexically + "v2.0.0+incompatible", + "v2.1.0" + ); + + for (int i = 0; i < ascending.size(); i++) { + for (int j = 0; j < ascending.size(); j++) { + int want = Integer.compare(i, j); + int got = sign(GoSemver.compare(ascending.get(i), ascending.get(j))); + assertThat(got) + .as("compare(%s, %s)", ascending.get(i), ascending.get(j)) + .isEqualTo(want); + } + } + } + + @Test + void buildMetadataIsIgnored() { + assertThat(GoSemver.compare("v1.0.0+build.1", "v1.0.0")).isZero(); + assertThat(GoSemver.compare("v1.0.0+build.1", "v1.0.0+build.2")).isZero(); + // +incompatible is build metadata: equal to the same version without it. + assertThat(GoSemver.compare("v2.0.0+incompatible", "v2.0.0")).isZero(); + } + + @Test + void missingMinorAndPatchDefaultToZero() { + assertThat(GoSemver.compare("v1", "v1.0.0")).isZero(); + assertThat(GoSemver.compare("v1.2", "v1.2.0")).isZero(); + assertThat(GoSemver.compare("v1", "v1.0.1")).isNegative(); + } + + @Test + void invalidVersionsSortBelowValidAndEqualEachOther() { + assertThat(GoSemver.compare("", "v0.0.0")).isNegative(); + assertThat(GoSemver.compare("1.0.0", "v1.0.0")).isNegative(); // missing 'v' + assertThat(GoSemver.compare("v01.0.0", "v1.0.0")).isNegative(); // leading zero invalid + assertThat(GoSemver.compare("garbage", "also-garbage")).isZero(); + } + + @Test + void sortingShuffledVersionsYieldsGoOrder() { + List versions = new ArrayList<>(List.of( + "v1.2.0", "v1.0.0", "v1.0.0-rc.1", "v0.9.0", "v1.0.0-alpha", "v1.10.0" + )); + Collections.shuffle(versions, new java.util.Random(42)); + versions.sort(GoSemver::compare); + assertThat(versions).containsExactly( + "v0.9.0", "v1.0.0-alpha", "v1.0.0-rc.1", "v1.0.0", "v1.2.0", "v1.10.0" + ); + } +} From 34538f358999533fde71b2c153eebfea0e556531 Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Tue, 23 Jun 2026 16:30:05 -0700 Subject: [PATCH 13/19] Go: Java module resolver, phase 3 (pruned MVS Resolve) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port modgraph.Resolve to Java: the go1.17+ pruned module graph and MVS build list. Every loaded module's requirements become build-list nodes, but recursion only continues through unpruned (go<1.17) modules; iterative MVS raises a node's selected version and re-loads it as needed. Dependency go.mods come entirely from the ModSource (no process execution). ResolverTest validates against the real toolchain end to end: it resolves a cobra-requiring module by fetching every dependency go.mod from the GOPROXY via HttpUrlConnectionSender, and asserts the build list equals `go list -m all`. Ran green (not skipped) against go1.26 + the live proxy. Module/zip hashes (go.sum material) are intentionally omitted — go mod tidy's require-set computation does not need them; that can follow when go.sum support is ported. --- .../internal/modgraph/ResolveResult.java | 77 +++++++ .../golang/internal/modgraph/Resolver.java | 209 ++++++++++++++++++ .../internal/modgraph/ResolverTest.java | 114 ++++++++++ 3 files changed, 400 insertions(+) create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ResolveResult.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Resolver.java create mode 100644 rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/ResolverTest.java diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ResolveResult.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ResolveResult.java new file mode 100644 index 00000000000..d209196e9eb --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ResolveResult.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import java.util.ArrayList; +import java.util.List; + +/** + * The resolved go1.17+ pruned module graph: the MVS-selected version of every + * module ({@link #buildList}, mirroring {@code go list -m all}) and the require + * edges between modules ({@link #graph}, mirroring {@code go mod graph}). + * {@link #complete} is false when any dependency metadata could not be + * read/fetched, making the result best-effort. + */ +public final class ResolveResult { + + final List buildList = new ArrayList<>(); + final List graph = new ArrayList<>(); + boolean complete = true; + + public List buildList() { + return buildList; + } + + public List graph() { + return graph; + } + + public boolean complete() { + return complete; + } + + /** One node of the build list at its MVS-selected version. */ + public static final class Module { + public final String path; + public final String version; // empty for the main module + public final String goVersion; // the module's own `go` directive, "" if absent + public final boolean main; + + public Module(String path, String version, String goVersion, boolean main) { + this.path = path; + this.version = version; + this.goVersion = goVersion; + this.main = main; + } + } + + /** A require edge from one module to another. */ + public static final class Edge { + public final String fromPath; + public final String fromVersion; // empty when From is the main module + public final String toPath; + public final String toVersion; + public final boolean indirect; + + public Edge(String fromPath, String fromVersion, String toPath, String toVersion, boolean indirect) { + this.fromPath = fromPath; + this.fromVersion = fromVersion; + this.toPath = toPath; + this.toVersion = toVersion; + this.indirect = indirect; + } + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Resolver.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Resolver.java new file mode 100644 index 00000000000..cd68ac5486d --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Resolver.java @@ -0,0 +1,209 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.jspecify.annotations.Nullable; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Builds the module graph and (pruned) build list for a main module, mirroring + * the go1.17+ pruned module graph: every loaded module's requirements become + * build-list NODES, but a module's requirements are only RECURSED into when that + * module is unpruned (its {@code go} directive is < 1.17). The resulting build + * list matches {@code go list -m all}. Performs no process execution — all + * dependency go.mods come from the {@link ModSource}. + */ +public final class Resolver { + + private Resolver() { + } + + private static final class MV { + final String path; + final String version; + + MV(String path, String version) { + this.path = path; + this.version = version; + } + } + + public static ResolveResult resolve(byte[] mainGoMod, ModSource src) { + ResolveResult res = new ResolveResult(); + GoModFile mf = GoModFile.parse(new String(mainGoMod, StandardCharsets.UTF_8)); + + // Main-module version replacements, keyed by "path" and "path@version". + Map replace = new HashMap<>(); + for (GoModFile.Replace r : mf.replaces()) { + if (r.newVersion == null) { // local filesystem replace — can't resolve from a source + res.complete = false; + continue; + } + MV nv = new MV(r.newPath, r.newVersion); + replace.put(r.oldPath, nv); + replace.put(r.oldPath + "@" + (r.oldVersion == null ? "" : r.oldVersion), nv); + } + + String mainPath = mf.modulePath() == null ? "" : mf.modulePath(); + String mainGo = mf.goVersion() == null ? "" : mf.goVersion(); + + Map present = new HashMap<>(); // path -> MVS-selected version + Map goVersionAt = new HashMap<>(); // path@version -> go directive + Set goModLoaded = new HashSet<>(); // path@version we fetched the go.mod for + Set loadPath = new HashSet<>(); // paths we recurse into + Set enqueued = new HashSet<>(); + Deque loadQueue = new ArrayDeque<>(); + + Node node = new Node(present, loadPath, enqueued, loadQueue); + + // Roots: the main module's requirements. + for (GoModFile.Require r : mf.requires()) { + MV to = applyReplace(replace, r.path, r.version); + res.graph.add(new ResolveResult.Edge(mainPath, "", to.path, to.version, r.indirect)); + node.setNode(to); + node.markLoad(to); + } + + while (!loadQueue.isEmpty()) { + MV cur = loadQueue.poll(); + if (!cur.version.equals(present.get(cur.path))) { + continue; // superseded by a higher selected version + } + String key = cur.path + "@" + cur.version; + byte[] b = src.goMod(cur.path, cur.version); + if (b == null) { + res.complete = false; + continue; + } + GoModFile df = GoModFile.parse(new String(b, StandardCharsets.UTF_8)); + goModLoaded.add(key); + String goV = df.goVersion() == null ? "" : df.goVersion(); + goVersionAt.put(key, goV); + boolean unpruned = goUnpruned(goV); + for (GoModFile.Require r : df.requires()) { + MV to = applyReplace(replace, r.path, r.version); + res.graph.add(new ResolveResult.Edge(cur.path, cur.version, to.path, to.version, r.indirect)); + node.setNode(to); // every requirement of a loaded module is a node + if (unpruned) { + node.markLoad(to); // recurse only through unpruned (go<1.17) modules + } + } + } + + // Assemble the build list. For a leaf node not recursed into, fetch its + // go.mod once to recover the `go` directive (the pruning pass needs it). + res.buildList.add(new ResolveResult.Module(mainPath, "", mainGo, true)); + for (Map.Entry e : present.entrySet()) { + String path = e.getKey(); + String version = e.getValue(); + String key = path + "@" + version; + String goV = goVersionAt.get(key); + if (goV == null && !goModLoaded.contains(key)) { + byte[] b = src.goMod(path, version); + if (b != null) { + String dv = GoModFile.parse(new String(b, StandardCharsets.UTF_8)).goVersion(); + if (dv != null) { + goV = dv; + } + } + } + res.buildList.add(new ResolveResult.Module(path, version, goV == null ? "" : goV, false)); + } + return res; + } + + private static MV applyReplace(Map replace, String path, String version) { + MV nv = replace.get(path + "@" + version); + if (nv != null) { + return nv; + } + nv = replace.get(path); + if (nv != null) { + return nv; + } + return new MV(path, version); + } + + /** + * Whether a module with the given {@code go} directive is UNPRUNED + * (go < 1.17), meaning its transitive requirements are part of the graph. + * An empty/invalid directive is treated as unpruned (pre-1.16 behavior). + */ + static boolean goUnpruned(@Nullable String v) { + if (v == null || v.isEmpty()) { + return true; + } + String[] parts = v.split("\\.", 3); + if (parts.length < 2) { + return true; + } + try { + int maj = Integer.parseInt(parts[0]); + int min = Integer.parseInt(parts[1]); + return maj < 1 || (maj == 1 && min < 17); + } catch (NumberFormatException ex) { + return true; + } + } + + /** Bundles the MVS bookkeeping closures (setNode / markLoad) over shared state. */ + private static final class Node { + final Map present; + final Set loadPath; + final Set enqueued; + final Deque loadQueue; + + Node(Map present, Set loadPath, Set enqueued, Deque loadQueue) { + this.present = present; + this.loadPath = loadPath; + this.enqueued = enqueued; + this.loadQueue = loadQueue; + } + + void enqueueLoad(MV m) { + String k = m.path + "@" + m.version; + if (enqueued.add(k)) { + loadQueue.add(m); + } + } + + // Record a build-list node at its highest seen version; re-enqueue a + // load-path whose version is raised (simple iterative MVS). + void setNode(MV m) { + String v = present.get(m.path); + if (v == null || GoSemver.compare(m.version, v) > 0) { + present.put(m.path, m.version); + if (loadPath.contains(m.path)) { + enqueueLoad(m); + } + } + } + + void markLoad(MV m) { + loadPath.add(m.path); + enqueueLoad(m); + } + } +} diff --git a/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/ResolverTest.java b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/ResolverTest.java new file mode 100644 index 00000000000..a49d51e82fd --- /dev/null +++ b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/ResolverTest.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openrewrite.ipc.http.HttpUrlConnectionSender; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Validates the Java pruned-MVS resolver against the real {@code go} toolchain: + * the build list it computes (fetching every dependency go.mod from the GOPROXY + * via {@link HttpUrlConnectionSender}) must agree with {@code go list -m all}. + * The toolchain is used only as the golden — the resolver never execs. + */ +class ResolverTest { + + @Test + void buildListMatchesGoListMAll(@TempDir Path dir) throws Exception { + assumeTrue(hasGo(), "needs the go toolchain + network"); + + write(dir, "go.mod", "module example.com/resolvertest\n\ngo 1.21\n\nrequire github.com/spf13/cobra v1.8.0\n"); + write(dir, "main.go", "package main\n\nimport _ \"github.com/spf13/cobra\"\n\nfunc main() {}\n"); + runGo(dir, "mod", "tidy"); + + Map golden = listModVersions(dir); // path -> version, sans main module + assumeTrue(!golden.isEmpty(), "go list produced nothing (offline?)"); + + byte[] goMod = Files.readAllBytes(dir.resolve("go.mod")); + String goproxy = runGo(dir, "env", "GOPROXY").trim(); + ModSource src = new ProxySource(goproxy, new HttpUrlConnectionSender()); + + ResolveResult res = Resolver.resolve(goMod, src); + assertThat(res.complete()).as("expected a complete resolution").isTrue(); + + Map ours = new HashMap<>(); + for (ResolveResult.Module m : res.buildList()) { + if (!m.main) { + ours.put(m.path, m.version); + } + } + + // Every module go selected, at the same version; and no extras. + assertThat(ours).containsExactlyInAnyOrderEntriesOf(golden); + } + + private static Map listModVersions(Path dir) throws Exception { + String out = runGo(dir, "list", "-m", "-f", "{{.Path}} {{.Version}}", "all"); + Map m = new HashMap<>(); + for (String line : out.split("\n")) { + String[] parts = line.trim().split("\\s+"); + if (parts.length == 2 && !parts[1].isEmpty()) { // skip the main module (no version) + m.put(parts[0], parts[1]); + } + } + return m; + } + + private static boolean hasGo() { + try { + return new ProcessBuilder("go", "version").start().waitFor() == 0; + } catch (Exception e) { + return false; + } + } + + private static void write(Path dir, String name, String content) throws Exception { + Files.write(dir.resolve(name), content.getBytes(StandardCharsets.UTF_8)); + } + + private static String runGo(Path dir, String... args) throws Exception { + String[] cmd = new String[args.length + 1]; + cmd[0] = "go"; + System.arraycopy(args, 0, cmd, 1, args.length); + ProcessBuilder pb = new ProcessBuilder(cmd).directory(dir.toFile()).redirectErrorStream(true); + pb.environment().put("GOFLAGS", "-mod=mod"); + Process p = pb.start(); + String out; + try (InputStream in = p.getInputStream()) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + bos.write(buf, 0, n); + } + out = new String(bos.toByteArray(), StandardCharsets.UTF_8); + } + p.waitFor(); + return out; + } +} From f1aeb35dccfe665b78c3e2368b7837a10e764086 Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Tue, 23 Jun 2026 16:42:27 -0700 Subject: [PATCH 14/19] Go: Java module resolver, phase 4 (tidy require set + pruning-completeness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port NeededModules and TidyRequireSet to Java. NeededModules walks the package import graph from the main module's imports (tests included) and classifies direct vs indirect against the build list. TidyRequireSet adds the go1.17+ pruning-completeness roots: it walks imports+tests frontier-by-frontier, and at each frontier promotes any module the pruned in-memory MVS under-selects — mirroring cmd/go/internal/modload.tidyPrunedRoots. ReqIndex seeds the pruned MVS from the resolved graph and lazily fetches only promoted roots; prunedSelectInMemory runs the selection with no per-iteration re-resolution. TidyTest validates against `go mod tidy` for the five scenarios that stress the pruning: no-extras, testify test-transitive (kr/text via check.v1), gin's real promotions, conc's "must not over-promote", and conc's depth-ordering (promote kr/pretty but not kr/text). All match the toolchain exactly. Also fixes GoImports to read backtick (raw-string) import paths — bytedance/sonic writes its imports that way, and missing them dropped sonic's six assembly-related indirect deps from the gin result. Caught by the gin golden case. --- .../golang/internal/modgraph/GoImports.java | 22 +- .../golang/internal/modgraph/ModVer.java | 27 ++ .../golang/internal/modgraph/ReqIndex.java | 116 ++++++ .../golang/internal/modgraph/RequireSet.java | 52 +++ .../golang/internal/modgraph/Tidy.java | 362 ++++++++++++++++++ .../internal/modgraph/GoImportsTest.java | 18 + .../golang/internal/modgraph/TidyTest.java | 161 ++++++++ 7 files changed, 753 insertions(+), 5 deletions(-) create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModVer.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ReqIndex.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/RequireSet.java create mode 100644 rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Tidy.java create mode 100644 rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/TidyTest.java diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoImports.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoImports.java index 0b26b425393..6c535fc6713 100644 --- a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoImports.java +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/GoImports.java @@ -81,7 +81,7 @@ private void readImportSpec(List out) { return; } char c = peek(); - if (c == '"') { + if (c == '"' || c == '`') { out.add(readString()); return; } @@ -92,7 +92,7 @@ private void readImportSpec(List out) { readIdent(); } skipSpaceAndComments(); - if (!atEnd() && peek() == '"') { + if (!atEnd() && (peek() == '"' || peek() == '`')) { out.add(readString()); } return; @@ -121,11 +121,23 @@ private void skipSpaceAndComments() { } } - // Reads a double-quoted Go string literal (peek == '"'), returning its - // unescaped content. Import paths only ever use the basic escapes. + // Reads a Go string literal import path (peek == '"' or '`'), returning its + // content. Raw strings (backticks) take no escapes; interpreted strings + // ("...") only ever use the basic escapes in an import path. Go code in the + // wild uses both forms — e.g. bytedance/sonic writes imports in backticks. private String readString() { - i++; // consume opening quote + char quote = s.charAt(i++); // consume opening quote (" or `) StringBuilder sb = new StringBuilder(); + if (quote == '`') { + while (!atEnd()) { + char c = s.charAt(i++); + if (c == '`') { + break; + } + sb.append(c); + } + return sb.toString(); + } while (!atEnd()) { char c = s.charAt(i++); if (c == '\\' && !atEnd()) { diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModVer.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModVer.java new file mode 100644 index 00000000000..32f58b5e2bd --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ModVer.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +/** A module path at a specific version (the analog of {@code module.Version}). */ +final class ModVer { + final String path; + final String version; + + ModVer(String path, String version) { + this.path = path; + this.version = version; + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ReqIndex.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ReqIndex.java new file mode 100644 index 00000000000..62689f7ffe8 --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/ReqIndex.java @@ -0,0 +1,116 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.jspecify.annotations.Nullable; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Memoizes each module version's direct requirements (post-replace) and {@code + * go} directive. Seeded for free from an already-resolved graph — whose edges + * cover every LOADED module — and lazily fetches the go.mod of any module that + * pruning left unloaded (only the handful of pruned modules promoted to roots). + * This lets {@link Tidy#prunedSelectInMemory} run a pruned MVS with no + * per-iteration re-resolution. + */ +final class ReqIndex { + + private final Map> edges = new HashMap<>(); // path@version -> requires (post-replace) + private final Map gover = new HashMap<>(); // path@version -> go directive + private final Set known = new HashSet<>(); // keys whose edges are fully populated + private final Map replace = new HashMap<>(); // main module's version replacements + private final ModSource src; + + /** A module version's direct requirements and its {@code go} directive. */ + static final class Reqs { + final List reqs; + final @Nullable String goVersion; + + Reqs(List reqs, @Nullable String goVersion) { + this.reqs = reqs; + this.goVersion = goVersion; + } + } + + ReqIndex(ResolveResult res, String mainGoMod, ModSource src) { + this.src = src; + + // Main module's version replacements (local-path replaces are skipped; they + // already mark the resolution incomplete upstream). + GoModFile mf = GoModFile.parse(mainGoMod); + for (GoModFile.Replace r : mf.replaces()) { + if (r.newVersion == null) { + continue; + } + ModVer nv = new ModVer(r.newPath, r.newVersion); + replace.put(r.oldPath, nv); + replace.put(r.oldPath + "@" + (r.oldVersion == null ? "" : r.oldVersion), nv); + } + // Seed edges from the resolved graph. Every LOADED module contributes all of + // its require edges here (already post-replace), so its key is fully known. + for (ResolveResult.Edge e : res.graph()) { + String key = e.fromPath + "@" + e.fromVersion; + edges.computeIfAbsent(key, k -> new ArrayList<>()).add(new ModVer(e.toPath, e.toVersion)); + known.add(key); + } + for (ResolveResult.Module m : res.buildList()) { + gover.put(m.path + "@" + m.version, m.goVersion); + } + } + + /** Direct requirements + go directive, fetching/memoizing the go.mod on a miss. */ + Reqs requires(String path, String version) { + String key = path + "@" + version; + if (known.contains(key)) { + return new Reqs(edges.getOrDefault(key, Collections.emptyList()), gover.get(key)); + } + known.add(key); // memoize even on miss, so a failed fetch isn't retried + byte[] b = src.goMod(path, version); + if (b == null) { + return new Reqs(Collections.emptyList(), gover.get(key)); + } + GoModFile df = GoModFile.parse(new String(b, StandardCharsets.UTF_8)); + if (df.goVersion() != null) { + gover.put(key, df.goVersion()); + } + List reqs = new ArrayList<>(); + for (GoModFile.Require r : df.requires()) { + reqs.add(applyReplace(new ModVer(r.path, r.version))); + } + edges.put(key, reqs); + return new Reqs(reqs, gover.get(key)); + } + + private ModVer applyReplace(ModVer m) { + ModVer nv = replace.get(m.path + "@" + m.version); + if (nv != null) { + return nv; + } + nv = replace.get(m.path); + if (nv != null) { + return nv; + } + return m; + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/RequireSet.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/RequireSet.java new file mode 100644 index 00000000000..929a07c3026 --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/RequireSet.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The set of modules {@code go mod tidy} would write to go.mod, classified the + * way tidy classifies them. Computed from the package import graph (not just the + * module graph), so it matches go.mod exactly. + */ +public final class RequireSet { + + /** Module path -> version for modules providing a package the main module imports directly. */ + public final Map direct = new LinkedHashMap<>(); + /** Module path -> version for modules needed transitively but not imported directly. */ + public final Map indirect = new LinkedHashMap<>(); + /** False if any package directory could not be read, making the set best-effort. */ + public boolean complete = true; + /** Import paths that mapped to no build-list module (diagnostic only). */ + public final List unresolved = new ArrayList<>(); + /** Package directories that could not be read (diagnostic only). */ + public final List missingDirs = new ArrayList<>(); + + public Map direct() { + return direct; + } + + public Map indirect() { + return indirect; + } + + public boolean complete() { + return complete; + } +} diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Tidy.java b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Tidy.java new file mode 100644 index 00000000000..26acc2ea579 --- /dev/null +++ b/rewrite-go/src/main/java/org/openrewrite/golang/internal/modgraph/Tidy.java @@ -0,0 +1,362 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.jspecify.annotations.Nullable; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Computes the exact go.mod require set {@code go mod tidy} would write, by + * walking the PACKAGE import graph (not just the module graph). {@link + * #neededModules} gives the modules that provide a package in {@code all}; + * {@link #tidyRequireSet} additionally adds the go1.17+ pruning-completeness + * roots — test-transitive modules the pruned graph would under-select — mirroring + * {@code cmd/go/internal/modload.tidyPrunedRoots}. No {@code go} execution. + */ +public final class Tidy { + + private Tidy() { + } + + public static RequireSet neededModules(List mainImports, String mainModulePath, + ResolveResult res, ModSource src, boolean separateIndirect) { + RequireSet rs = new RequireSet(); + List mods = nonMain(res); + + Map needed = new HashMap<>(); + Set direct = new HashSet<>(); + Set visited = new HashSet<>(); + Deque queue = new ArrayDeque<>(); + + for (String imp : mainImports) { + if (isStdlibImport(imp) || isLocal(imp, mainModulePath)) { + continue; + } + ModVer mv = moduleOf(mods, imp); + if (mv == null) { + rs.complete = false; + continue; + } + direct.add(mv.path); + needed.put(mv.path, mv.version); + queue.add(imp); + } + + while (!queue.isEmpty()) { + String imp = queue.poll(); + if (!visited.add(imp)) { + continue; + } + if (isStdlibImport(imp) || isLocal(imp, mainModulePath)) { + continue; + } + ModVer mv = moduleOf(mods, imp); + if (mv == null) { + // Maps to no build-list module: not part of the real build (e.g. a + // //go:build ignore generator or an unselected platform). Diagnostic + // only — not a missing dependency. + rs.unresolved.add(imp); + continue; + } + needed.put(mv.path, mv.version); + + List deps = packageImports(src, mv.path, mv.version, imp); + if (deps == null) { + rs.complete = false; + rs.missingDirs.add(imp); + continue; + } + for (String dep : deps) { + if (isStdlibImport(dep) || isLocal(dep, mainModulePath) || visited.contains(dep)) { + continue; + } + queue.add(dep); + } + } + + // For go < 1.17, omit indirect modules implied by another needed module's + // go.mod (already pinned by the graph). go >= 1.17 records the full set. + Set implied = new HashSet<>(); + if (!separateIndirect) { + for (ResolveResult.Edge e : res.graph()) { + if (!e.fromPath.isEmpty() && !e.fromPath.equals(mainModulePath) && needed.containsKey(e.fromPath)) { + implied.add(e.toPath); + } + } + } + + for (Map.Entry en : needed.entrySet()) { + String mod = en.getKey(); + if (direct.contains(mod)) { + rs.direct.put(mod, en.getValue()); + } else if (!implied.contains(mod)) { + rs.indirect.put(mod, en.getValue()); + } + } + return rs; + } + + public static RequireSet tidyRequireSet(List mainImports, String mainModulePath, String mainGoMod, + ResolveResult res, ModSource src, boolean separateIndirect) { + RequireSet base = neededModules(mainImports, mainModulePath, res, src, separateIndirect); + if (!separateIndirect || !base.complete) { + return base; + } + + Map loaded = new HashMap<>(); + List mods = new ArrayList<>(); + for (ResolveResult.Module m : res.buildList()) { + if (!m.main) { + loaded.put(m.path, m.version); + mods.add(m); + } + } + + // Current root set: everything NeededModules already requires. + Map roots = new HashMap<>(); + roots.putAll(base.direct); + roots.putAll(base.indirect); + + ReqIndex idx = new ReqIndex(res, mainGoMod, src); + + // Walk the import graph FRONTIER BY FRONTIER (increasing import-stack + // depth). At each frontier recompute the pruned selection under the roots + // so far, then promote any frontier module the pruned graph under-selects. + // This ordering pins a shallow module's deeper requirements before they are + // examined, so they are not wrongly promoted. Test imports are deferred one + // frontier deeper (go's separate `.test` node). + Set queued = new HashSet<>(); + List queue = new ArrayList<>(); + for (String imp : mainImports) { + enq(imp, false, mainModulePath, queued, queue); + } + + while (!queue.isEmpty()) { + Map sel = prunedSelectInMemory(roots, idx); + List frontier = queue; + queue = new ArrayList<>(); + for (QItem it : frontier) { + ModVer mv = moduleOf(mods, it.path); + if (mv == null) { + continue; + } + PkgImports pi = packageImportsWithTests(src, mv.path, mv.version, it.path); + if (pi != null) { + if (it.isTest) { + for (String d : pi.testImports) { + enq(d, false, mainModulePath, queued, queue); + } + } else { + for (String d : pi.imports) { + enq(d, false, mainModulePath, queued, queue); + } + enq(it.path, true, mainModulePath, queued, queue); // the test node, one frontier deeper + } + } + if (!roots.containsKey(mv.path)) { + String want = loaded.get(mv.path); + String have = sel.get(mv.path); + if (want != null && !want.isEmpty() && (have == null || GoSemver.compare(have, want) < 0)) { + roots.put(mv.path, want); + } + } + } + } + + // Reclassify: direct stays direct; every other root is indirect. + RequireSet out = new RequireSet(); + out.unresolved.addAll(base.unresolved); + out.missingDirs.addAll(base.missingDirs); + for (Map.Entry e : roots.entrySet()) { + if (base.direct.containsKey(e.getKey())) { + out.direct.put(e.getKey(), e.getValue()); + } else { + out.indirect.put(e.getKey(), e.getValue()); + } + } + return out; + } + + // ---- helpers ----------------------------------------------------------- + + private static List nonMain(ResolveResult res) { + List mods = new ArrayList<>(); + for (ResolveResult.Module m : res.buildList()) { + if (!m.main) { + mods.add(m); + } + } + return mods; + } + + /** The longest build-list module path that is a prefix of importPath, or null. */ + private static @Nullable ModVer moduleOf(List mods, String importPath) { + String best = ""; + String bestVer = ""; + for (ResolveResult.Module m : mods) { + if (importPath.equals(m.path) || importPath.startsWith(m.path + "/")) { + if (m.path.length() > best.length()) { + best = m.path; + bestVer = m.version; + } + } + } + return best.isEmpty() ? null : new ModVer(best, bestVer); + } + + private static boolean isLocal(String importPath, String mainModulePath) { + return importPath.equals(mainModulePath) || importPath.startsWith(mainModulePath + "/"); + } + + /** A standard-library import: no dot in its first path segment. */ + static boolean isStdlibImport(String importPath) { + int i = importPath.indexOf('/'); + String first = i >= 0 ? importPath.substring(0, i) : importPath; + return !first.contains("."); + } + + /** Non-test imports of a package, or null if its sources could not be read. */ + private static @Nullable List packageImports(ModSource src, String mod, String version, String importPath) { + Map files = src.packageGoFiles(mod, version, importPath); + if (files == null) { + return null; + } + Set set = new HashSet<>(); + for (Map.Entry e : files.entrySet()) { + if (e.getKey().endsWith("_test.go")) { + continue; + } + for (String p : GoImports.parse(new String(e.getValue(), StandardCharsets.UTF_8))) { + if (!p.isEmpty()) { + set.add(p); + } + } + } + return new ArrayList<>(set); + } + + /** A package's ordinary and test imports, separately; null if unreadable. */ + private static @Nullable PkgImports packageImportsWithTests(ModSource src, String mod, String version, String importPath) { + Map files = src.packageGoFiles(mod, version, importPath); + if (files == null) { + return null; + } + Set imp = new HashSet<>(); + Set test = new HashSet<>(); + for (Map.Entry e : files.entrySet()) { + Set target = e.getKey().endsWith("_test.go") ? test : imp; + for (String p : GoImports.parse(new String(e.getValue(), StandardCharsets.UTF_8))) { + if (!p.isEmpty()) { + target.add(p); + } + } + } + return new PkgImports(new ArrayList<>(imp), new ArrayList<>(test)); + } + + private static void enq(String path, boolean isTest, String mainModulePath, Set queued, List queue) { + if (isStdlibImport(path) || isLocal(path, mainModulePath)) { + return; + } + String k = isTest ? path + "t" : path; + if (queued.add(k)) { + queue.add(new QItem(path, isTest)); + } + } + + private static final class QItem { + final String path; + final boolean isTest; + + QItem(String path, boolean isTest) { + this.path = path; + this.isTest = isTest; + } + } + + private static final class PkgImports { + final List imports; + final List testImports; + + PkgImports(List imports, List testImports) { + this.imports = imports; + this.testImports = testImports; + } + } + + /** + * MVS-selected version of every module under the go1.17+ pruned graph rooted + * at {@code roots}, reading requirements from {@code idx}. Mirrors {@link + * Resolver} exactly: loaded modules' requires become nodes, but recursion + * continues only through unpruned (go < 1.17) modules. + */ + static Map prunedSelectInMemory(Map roots, ReqIndex idx) { + Map present = new HashMap<>(); + Set loadPath = new HashSet<>(); + Set enqueued = new HashSet<>(); + Deque queue = new ArrayDeque<>(); + + for (Map.Entry e : roots.entrySet()) { + ModVer m = new ModVer(e.getKey(), e.getValue()); + setNode(present, loadPath, enqueued, queue, m); + loadPath.add(m.path); + enqueue(enqueued, queue, m); + } + + while (!queue.isEmpty()) { + ModVer cur = queue.poll(); + if (!cur.version.equals(present.get(cur.path))) { + continue; + } + ReqIndex.Reqs r = idx.requires(cur.path, cur.version); + boolean unpruned = Resolver.goUnpruned(r.goVersion); + for (ModVer req : r.reqs) { + setNode(present, loadPath, enqueued, queue, req); + if (unpruned) { + loadPath.add(req.path); + enqueue(enqueued, queue, req); + } + } + } + return present; + } + + private static void enqueue(Set enqueued, Deque queue, ModVer m) { + if (enqueued.add(m.path + "@" + m.version)) { + queue.add(m); + } + } + + private static void setNode(Map present, Set loadPath, Set enqueued, + Deque queue, ModVer m) { + String v = present.get(m.path); + if (v == null || GoSemver.compare(m.version, v) > 0) { + present.put(m.path, m.version); + if (loadPath.contains(m.path)) { + enqueue(enqueued, queue, m); + } + } + } +} diff --git a/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoImportsTest.java b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoImportsTest.java index 4d583d36048..e039839efc7 100644 --- a/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoImportsTest.java +++ b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/GoImportsTest.java @@ -86,6 +86,24 @@ void buildConstrainedFileStillYieldsImports() { assertThat(GoImports.parse(src)).containsExactly("github.com/inconshreveable/mousetrap"); } + @Test + void backtickRawStringImports() { + // bytedance/sonic (and others) write import paths as raw strings. + String src = + "package p\n" + + "\n" + + "import (\n" + + "\t`github.com/bytedance/sonic/ast`\n" + + "\t_ `github.com/bytedance/sonic/internal/rt`\n" + + ")\n" + + "\n" + + "import `golang.org/x/arch/x86/x86asm`\n"; + assertThat(GoImports.parse(src)).containsExactlyInAnyOrder( + "github.com/bytedance/sonic/ast", + "github.com/bytedance/sonic/internal/rt", + "golang.org/x/arch/x86/x86asm"); + } + @Test void noImports() { assertThat(GoImports.parse("package p\n\nfunc main() {}\n")).isEmpty(); diff --git a/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/TidyTest.java b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/TidyTest.java new file mode 100644 index 00000000000..7081fb9158c --- /dev/null +++ b/rewrite-go/src/test/java/org/openrewrite/golang/internal/modgraph/TidyTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.internal.modgraph; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openrewrite.ipc.http.HttpUrlConnectionSender; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Validates the Java require-set computation against {@code go mod tidy} for the + * scenarios that stress go1.17+ pruning-completeness: a test-transitive indirect + * (kr/text via check.v1), gin's genuine promotions, and the conc depth-ordering + * case where kr/pretty must be promoted but kr/text must NOT. The toolchain is + * the golden; the resolver fetches all metadata from the proxy via HttpSender. + */ +class TidyTest { + + @Test + void noExtras(@TempDir Path dir) throws Exception { + assertMatchesGoModTidy(dir, "example.com/noextras", + "module example.com/noextras\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n", + "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n", + null); + } + + @Test + void testTransitive(@TempDir Path dir) throws Exception { + assertMatchesGoModTidy(dir, "example.com/testtrans", + "module example.com/testtrans\n\ngo 1.25.0\n\nrequire github.com/stretchr/testify v1.9.0\n", + "package main\n\nimport _ \"github.com/stretchr/testify/assert\"\n\nfunc main() {}\n", + null); + } + + @Test + void ginPruningCompleteness(@TempDir Path dir) throws Exception { + assertMatchesGoModTidy(dir, "example.com/ginapp", + "module example.com/ginapp\n\ngo 1.25.0\n\nrequire github.com/gin-gonic/gin v1.10.0\n", + "package main\n\nimport _ \"github.com/gin-gonic/gin\"\n\nfunc main() {}\n", + null); + } + + @Test + void testInTestFileDoesNotOverPromote(@TempDir Path dir) throws Exception { + assertMatchesGoModTidy(dir, "example.com/conctest", + "module example.com/conctest\n\ngo 1.20\n\nrequire github.com/stretchr/testify v1.8.1\n", + "package conctest\n", + "package conctest\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestX(t *testing.T) { assert.Equal(t, 1, 1) }\n"); + } + + @Test + void concDepthOrdering(@TempDir Path dir) throws Exception { + assertMatchesGoModTidy(dir, "github.com/sourcegraph/conc", + "module github.com/sourcegraph/conc\n\ngo 1.20\n\nrequire github.com/stretchr/testify v1.8.1\n\nrequire (\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/kr/pretty v0.3.0 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/rogpeppe/go-internal v1.9.0 // indirect\n\tgopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n", + "package conc\n", + "package conc\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestX(t *testing.T) { assert.Equal(t, 1, 1) }\n"); + } + + private void assertMatchesGoModTidy(Path dir, String modPath, String goMod, String mainGo, + @Nullable String testGo) throws Exception { + assumeTrue(hasGo(), "needs the go toolchain + network"); + write(dir, "go.mod", goMod); + write(dir, "main.go", mainGo); + if (testGo != null) { + write(dir, "main_test.go", testGo); + } + runGo(dir, "mod", "tidy"); + + byte[] tidied = Files.readAllBytes(dir.resolve("go.mod")); + String tidiedStr = new String(tidied, StandardCharsets.UTF_8); + GoModFile golden = GoModFile.parse(tidiedStr); + Set goldenDirect = new TreeSet<>(); + Set goldenIndirect = new TreeSet<>(); + for (GoModFile.Require r : golden.requires()) { + (r.indirect ? goldenIndirect : goldenDirect).add(r.path); + } + assumeTrue(!goldenDirect.isEmpty() || !goldenIndirect.isEmpty(), "go mod tidy produced nothing (offline?)"); + + List mainImports = scanMainImports(dir); + String goproxy = runGo(dir, "env", "GOPROXY").trim(); + ModSource src = new ProxySource(goproxy, new HttpUrlConnectionSender()); + + ResolveResult res = Resolver.resolve(tidied, src); + RequireSet rs = Tidy.tidyRequireSet(mainImports, modPath, tidiedStr, res, src, true); + + assertThat(rs.complete()).as("expected a complete TidyRequireSet").isTrue(); + assertThat(new TreeSet<>(rs.direct().keySet())).as("direct").isEqualTo(goldenDirect); + assertThat(new TreeSet<>(rs.indirect().keySet())).as("indirect").isEqualTo(goldenIndirect); + } + + /** Imports across every .go file of the (flat) main module, tests included. */ + private static List scanMainImports(Path dir) throws Exception { + Set imports = new LinkedHashSet<>(); + try (Stream files = Files.list(dir)) { + for (Path f : (Iterable) files.filter(p -> p.getFileName().toString().endsWith(".go"))::iterator) { + imports.addAll(GoImports.parse(new String(Files.readAllBytes(f), StandardCharsets.UTF_8))); + } + } + return new java.util.ArrayList<>(imports); + } + + private static boolean hasGo() { + try { + return new ProcessBuilder("go", "version").start().waitFor() == 0; + } catch (Exception e) { + return false; + } + } + + private static void write(Path dir, String name, String content) throws Exception { + Files.write(dir.resolve(name), content.getBytes(StandardCharsets.UTF_8)); + } + + private static String runGo(Path dir, String... args) throws Exception { + String[] cmd = new String[args.length + 1]; + cmd[0] = "go"; + System.arraycopy(args, 0, cmd, 1, args.length); + ProcessBuilder pb = new ProcessBuilder(cmd).directory(dir.toFile()).redirectErrorStream(true); + pb.environment().put("GOFLAGS", "-mod=mod"); + Process p = pb.start(); + String out; + try (InputStream in = p.getInputStream()) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + bos.write(buf, 0, n); + } + out = new String(bos.toByteArray(), StandardCharsets.UTF_8); + } + p.waitFor(); + return out; + } +} From bb4fe7d1fe1bf09d0d55095fc0ae4c1dad8dfd8d Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Tue, 23 Jun 2026 17:59:54 -0700 Subject: [PATCH 15/19] =?UTF-8?q?Go:=20phase=205=20=E2=80=94=20route=20go.?= =?UTF-8?q?mod=20tidy=20to=20the=20Java=20resolver;=20remove=20Go=20modgra?= =?UTF-8?q?ph=20+=20Http?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the port: the GoModTidy recipe no longer resolves dependencies in the Go peer. It now delegates the entire `go mod tidy` require-set computation to the pure-Java resolver on the host via a new domain RPC method, GoModResolveTidy. - core RewriteRpc: replace the generic, network-performing `Http` RPC method with a `registerLanguageMethods(JsonRpc)` extension hook (called before bind) and a protected getHttpSender(). The generic "host, fetch this URL for the peer" capability — the SSRF/coupling concern raised in review — is gone from the shared protocol. - GoRewriteRpc: register GoModResolveTidy, which builds a CacheSource+ProxySource from the request and runs Resolver + Tidy. All GOPROXY HTTP happens here, in the host, through the configured HttpSender; GOPROXY=off resolves cache-only. - Go side: resolveTidyViaJava sends {goMod, mainImports, modulePath, separateIndirect, goproxy, gomodcache} and applies the returned require set; computeTidySet calls it instead of the in-process resolver, falling back to the LST-only pass when no resolver is installed (offline). The parse-time marker now carries only the declared model; the resolved build list is computed on demand. - Delete the Go pkg/parser/modgraph package (its algorithm now lives in Java) and the parse-time resolveModuleGraph/moduleSource/fetchHTTP plumbing. Tested: GoModResolveTidyTest drives the exact handler entry point (resolveTidy) and matches `go mod tidy` for gin's pruning-completeness case, fetching over the proxy via HttpSender. Full rewrite-go Java suite, rewrite-core rpc tests, and the Go unit suite are green. Note: a full modw corpus sweep could not be run here — the moderne-cli core/serialization module does not compile against the workspace rewrite (pre-existing API skew, 128 errors in V3LstReader: LstMetadata, ChangesetFilter, EditPage, UsesMethod.getMethodPattern, …), so the dev fat jar cannot be rebuilt. This is unrelated to these changes (none touch those files) and predates them (the on-disk fat jar is from before this work). --- .../java/org/openrewrite/rpc/RewriteRpc.java | 58 +-- rewrite-go/cmd/rpc/main.go | 107 ++--- rewrite-go/pkg/parser/modgraph/marker.go | 85 ---- rewrite-go/pkg/parser/modgraph/modgraph.go | 285 ------------ .../pkg/parser/modgraph/modgraph_test.go | 424 ------------------ rewrite-go/pkg/parser/modgraph/needed.go | 246 ---------- rewrite-go/pkg/parser/modgraph/source.go | 394 ---------------- rewrite-go/pkg/parser/modgraph/tidy.go | 313 ------------- rewrite-go/pkg/parser/modgraph/tidy_test.go | 140 ------ .../pkg/parser/modgraph/writethrough_test.go | 135 ------ rewrite-go/pkg/recipe/golang/go_mod_tidy.go | 81 +--- .../pkg/recipe/golang/module_resolution.go | 65 +-- .../openrewrite/golang/rpc/GoRewriteRpc.java | 75 ++++ .../golang/rpc/GoModResolveTidyTest.java | 129 ++++++ rewrite-go/test/go_mod_resolution_test.go | 131 ------ 15 files changed, 311 insertions(+), 2357 deletions(-) delete mode 100644 rewrite-go/pkg/parser/modgraph/marker.go delete mode 100644 rewrite-go/pkg/parser/modgraph/modgraph.go delete mode 100644 rewrite-go/pkg/parser/modgraph/modgraph_test.go delete mode 100644 rewrite-go/pkg/parser/modgraph/needed.go delete mode 100644 rewrite-go/pkg/parser/modgraph/source.go delete mode 100644 rewrite-go/pkg/parser/modgraph/tidy.go delete mode 100644 rewrite-go/pkg/parser/modgraph/tidy_test.go delete mode 100644 rewrite-go/pkg/parser/modgraph/writethrough_test.go create mode 100644 rewrite-go/src/test/java/org/openrewrite/golang/rpc/GoModResolveTidyTest.java delete mode 100644 rewrite-go/test/go_mod_resolution_test.go diff --git a/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java b/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java index 0d88a66ac09..3eab13c8648 100644 --- a/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java +++ b/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java @@ -38,12 +38,8 @@ import org.openrewrite.HttpSenderExecutionContextView; import org.openrewrite.ipc.http.HttpSender; -import org.openrewrite.ipc.http.HttpUrlConnectionSender; -import java.io.ByteArrayOutputStream; -import java.io.InputStream; import java.io.PrintStream; -import java.util.Base64; import java.util.HashMap; import java.util.Map; import java.nio.file.Files; @@ -243,39 +239,28 @@ protected Boolean handle(Void noParams) { } }); - // Http: lets the RPC peer (e.g. the Go module-graph resolver fetching - // dependency go.mod files from a GOPROXY) perform an HTTP GET through - // the configured HttpSender, so proxy/auth/TLS are honored. Returns the - // status code and base64-encoded body. - jsonRpc.rpc("Http", new JsonRpcMethod() { - @Override - protected Object handle(HttpRequest request) { - HttpSender sender = httpSender != null ? httpSender : new HttpUrlConnectionSender(); - Map out = new HashMap<>(); - try (HttpSender.Response response = sender.get(request.url).send()) { - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - try (InputStream in = response.getBody()) { - byte[] buf = new byte[8192]; - for (int n; (n = in.read(buf)) != -1; ) { - bos.write(buf, 0, n); - } - } - out.put("status", response.getCode()); - out.put("body", Base64.getEncoder().encodeToString(bos.toByteArray())); - } catch (Exception e) { - out.put("status", 0); - out.put("body", ""); - } - return out; - } - }); + // Language-specific RPC methods (registered by subclasses) are bound + // here, before jsonRpc.bind(). This is where, for example, the Go + // support registers its module-graph resolver — keeping domain-specific, + // network-performing methods out of the generic core protocol. + registerLanguageMethods(jsonRpc); jsonRpc.bind(); } /** - * Configure the {@link HttpSender} used by the {@code Http} RPC method. - * Typically set from a parse/recipe ExecutionContext so the peer's HTTP + * Hook for a subclass to register additional, language-specific RPC methods + * on the bidirectional channel before it is bound. The default registers + * nothing. Implementations may delegate network calls to {@link + * #getHttpSender()} so proxy/auth/TLS are honored. Invoked from the + * constructor, so implementations must not rely on subclass instance state. + */ + protected void registerLanguageMethods(JsonRpc jsonRpc) { + } + + /** + * Configure the {@link HttpSender} a language method may use to perform HTTP + * on the peer's behalf. Typically set from a parse/recipe ExecutionContext so * fetches use the CLI-configured sender (proxy, auth, TLS). */ public RewriteRpc setHttpSender(@Nullable HttpSender httpSender) { @@ -283,12 +268,9 @@ public RewriteRpc setHttpSender(@Nullable HttpSender httpSender) { return this; } - /** - * Request body for the {@code Http} RPC method. - */ - static class HttpRequest { - public String url; - public @Nullable String method; + /** The configured {@link HttpSender}, for use by language RPC methods. */ + protected @Nullable HttpSender getHttpSender() { + return httpSender; } public RewriteRpc livenessCheck(Supplier livenessCheck) { diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index 1755cc992a3..c603155d5ed 100644 --- a/rewrite-go/cmd/rpc/main.go +++ b/rewrite-go/cmd/rpc/main.go @@ -20,7 +20,6 @@ package main import ( "bufio" - "encoding/base64" "encoding/csv" "encoding/json" "flag" @@ -40,7 +39,6 @@ import ( "github.com/grafana/pyroscope-go" goparser "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" - "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" "github.com/openrewrite/rewrite/rewrite-go/pkg/preconditions" "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" @@ -829,95 +827,53 @@ func mapMarkerPrinter(mp *string) printer.MarkerPrinter { } } -// fetchHTTP performs an HTTP GET by delegating to the Java side over -// bidirectional RPC, which executes the request through the CLI-configured -// OpenRewrite HttpSender (proxy, auth, TLS all honored). Returns the response -// body and status code. Used by the module-graph resolver to fetch dependency -// go.mod files from a GOPROXY when they are not in the local module cache. -func (s *server) fetchHTTP(url string) ([]byte, int, error) { +// resolveTidyViaJava computes the `go mod tidy` require set by delegating to the +// pure-Java module resolver over bidirectional RPC. The Java side performs all +// GOPROXY HTTP through the CLI-configured OpenRewrite HttpSender (proxy, auth, +// TLS honored) and writes fetched modules through to the standard module cache, +// so no network egress originates from this peer. We pass the GOPROXY/GOMODCACHE +// resolved from the environment; GOPROXY=off degrades to cache-only on the host. +func (s *server) resolveTidyViaJava(content string, mainImports []string, modulePath string, separateIndirect bool) (recipes.TidyRequireSet, bool) { s.httpMu.Lock() defer s.httpMu.Unlock() - params, _ := json.Marshal(map[string]any{"url": url, "method": "GET"}) + params, _ := json.Marshal(map[string]any{ + "goMod": content, + "mainImports": mainImports, + "modulePath": modulePath, + "separateIndirect": separateIndirect, + "goproxy": envOr("GOPROXY", "https://proxy.golang.org,direct"), + "gomodcache": envOr("GOMODCACHE", defaultModCache()), + }) req, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", - "id": "go-Http", - "method": "Http", + "id": "go-GoModResolveTidy", + "method": "GoModResolveTidy", "params": json.RawMessage(params), }) header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(req)) if _, err := s.writer.Write(append([]byte(header), req...)); err != nil { - return nil, 0, err + return recipes.TidyRequireSet{}, false } resp, err := s.readMessage() if err != nil { - return nil, 0, err + return recipes.TidyRequireSet{}, false } raw := resp.Result if raw == nil { raw = resp.Params } if raw == nil { - return nil, 0, fmt.Errorf("Http: empty response") + return recipes.TidyRequireSet{}, false } var out struct { - Status int `json:"status"` - Body string `json:"body"` // base64 + Direct map[string]string `json:"direct"` + Indirect map[string]string `json:"indirect"` + Complete bool `json:"complete"` } if err := json.Unmarshal(raw, &out); err != nil { - return nil, 0, err - } - body, err := base64.StdEncoding.DecodeString(out.Body) - if err != nil { - return nil, 0, err - } - return body, out.Status, nil -} - -// resolveModuleGraph enriches a go.mod's GoResolutionResult marker with the -// resolved module graph + build list. Dependency go.mod files are read from the -// local module cache first and fetched from the GOPROXY (via the Java -// HttpSender) on a miss. Best-effort: on any failure the marker keeps whatever -// was resolved and GraphComplete reflects partiality. -// -// Proxy fetching is on by default and disabled the Go-native way with -// GOPROXY=off. Fetched modules are written through to the standard module -// cache, so the first parse warms it and every later parse/recipe run — across -// projects on the machine — resolves the full graph offline. -func (s *server) resolveModuleGraph(goModContent []byte, mrr *golang.GoResolutionResult) { - res, err := modgraph.Resolve(goModContent, s.moduleSource()) - if err != nil { - s.logger.Printf("resolveModuleGraph: %v", err) - return + return recipes.TidyRequireSet{}, false } - modgraph.ApplyTo(res, mrr) -} - -// moduleSource builds the dependency-resolution source: the local module cache -// first, then (unless disabled) a write-through GOPROXY tier whose HTTP is -// delegated to the Java HttpSender via the bidirectional Http method. Proxy -// fetches persist .mod/.zip/.ziphash into the standard $GOMODCACHE/cache/download -// layout, so the cache fills in on first use and is shared by every subsequent -// lookup. The same source is used at parse time and installed into the recipe -// ExecutionContext for recipe-time re-resolution, giving both the full graph. -func (s *server) moduleSource() modgraph.ModSource { - gomodcache := envOr("GOMODCACHE", defaultModCache()) - sources := []modgraph.ModSource{modgraph.CacheSource(gomodcache)} - if proxyResolveEnabled() { - goproxy := envOr("GOPROXY", "https://proxy.golang.org,direct") - sources = append(sources, modgraph.ProxyWriteThroughSource(goproxy, gomodcache, s.fetchHTTP)) - } - return modgraph.TieredSource(sources...) -} - -// proxyResolveEnabled reports whether the GOPROXY tier should be added. Network -// module resolution is on by default — like rewrite's other ecosystems we -// attempt the network (via the CLI HttpSender) and degrade gracefully to the -// local cache and the existing require set when it is unavailable. It is -// disabled the Go-native way, GOPROXY=off, the standard mechanism for -// air-gapped builds. (A GOPROXY list such as "https://corp,off" still enables -// the proxy; only the bare value "off" means no network.) -func proxyResolveEnabled() bool { - return strings.TrimSpace(os.Getenv("GOPROXY")) != "off" + return recipes.TidyRequireSet{Direct: out.Direct, Indirect: out.Indirect, Complete: out.Complete}, true } func envOr(key, def string) string { @@ -1164,10 +1120,10 @@ func (s *server) resolveExecutionContext(pid *string) *recipe.ExecutionContext { s.preparedContexts[*pid] = ctx } s.installDataTableStore(ctx) - // Make the dependency-resolution source available so recipes that mutate - // dependencies can re-resolve the module model at recipe time (network via - // the Java HttpSender). - recipes.SetModSource(ctx, s.moduleSource()) + // Install the recipe-time tidy resolver, which delegates the require-set + // computation to the pure-Java module resolver on the host (all GOPROXY HTTP + // runs there through the CLI HttpSender). + recipes.SetTidyResolver(ctx, s.resolveTidyViaJava) return ctx } @@ -2083,9 +2039,8 @@ func (s *server) handleParseProject(params json.RawMessage) (any, *rpcError) { if sumData, err := os.ReadFile(sumPath); err == nil { mrr.ResolvedDependencies = goparser.ParseGoSum(string(sumData)) } - // Enrich with the resolved module graph + build list (cache, then - // GOPROXY via the Java HttpSender) so recipes get the full graph. - s.resolveModuleGraph(data, mrr) + // The marker carries only the declared model at parse time; the resolved + // build list is computed on demand by the Java resolver at recipe time. mods[filepath.Dir(modPath)] = &modCtx{dir: filepath.Dir(modPath), mrr: mrr} } diff --git a/rewrite-go/pkg/parser/modgraph/marker.go b/rewrite-go/pkg/parser/modgraph/marker.go deleted file mode 100644 index 96336242bb0..00000000000 --- a/rewrite-go/pkg/parser/modgraph/marker.go +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package modgraph - -import "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" - -// ToResolutionResult folds a resolved Result into a GoResolutionResult marker, -// populating the module-graph fields (BuildList/Graph/GraphComplete). The -// caller supplies the module identity fields parsed from go.mod. This is the -// single mapping used by both the parser wiring and the parity harness. -func ToResolutionResult(res Result, modulePath, goVersion, toolchain, path string) golang.GoResolutionResult { - m := golang.NewGoResolutionResult(modulePath, goVersion, toolchain, path) - ApplyTo(res, &m) - return m -} - -// FromMarker reconstructs a resolver Result from the module-graph fields of a -// GoResolutionResult marker, so recipes can run NeededModules (the package-import -// graph) at recipe time against the parse-time-resolved graph without re-fetching -// dependency go.mod files. -func FromMarker(m golang.GoResolutionResult) Result { - res := Result{Complete: m.GraphComplete} - for _, b := range m.BuildList { - res.BuildList = append(res.BuildList, Module{ - Path: b.ModulePath, - Version: b.Version, - GoVersion: b.GoVersion, - Main: b.Main, - ModuleHash: b.ModuleHash, - GoModHash: b.GoModHash, - }) - } - for _, e := range m.Graph { - res.Graph = append(res.Graph, Edge{ - FromPath: e.FromPath, - FromVersion: e.FromVersion, - ToPath: e.ToPath, - ToVersion: e.ToVersion, - Indirect: e.Indirect, - }) - } - return res -} - -// ApplyTo populates the module-graph fields (BuildList/Graph/GraphComplete) of -// an existing GoResolutionResult marker from a resolved Result. Used by the RPC -// parser to enrich the marker it already built from the go.mod text. -func ApplyTo(res Result, m *golang.GoResolutionResult) { - m.BuildList = make([]golang.GoModule, 0, len(res.BuildList)) - for _, b := range res.BuildList { - m.BuildList = append(m.BuildList, golang.GoModule{ - ModulePath: b.Path, - Version: b.Version, - GoVersion: b.GoVersion, - Main: b.Main, - ModuleHash: b.ModuleHash, - GoModHash: b.GoModHash, - }) - } - m.Graph = make([]golang.GoModuleEdge, 0, len(res.Graph)) - for _, e := range res.Graph { - m.Graph = append(m.Graph, golang.GoModuleEdge{ - FromPath: e.FromPath, - FromVersion: e.FromVersion, - ToPath: e.ToPath, - ToVersion: e.ToVersion, - Indirect: e.Indirect, - }) - } - m.GraphComplete = res.Complete -} diff --git a/rewrite-go/pkg/parser/modgraph/modgraph.go b/rewrite-go/pkg/parser/modgraph/modgraph.go deleted file mode 100644 index a1fe8fce2a9..00000000000 --- a/rewrite-go/pkg/parser/modgraph/modgraph.go +++ /dev/null @@ -1,285 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Package modgraph resolves a Go module's transitive module graph from the -// local module cache, WITHOUT invoking the `go` tool or touching the network. -// -// It exists so the parser/connector can compute the graph once, at ingest -// time, and freeze it into the GoResolutionResult marker — recipes then read -// the graph as pure data and never perform I/O. -// -// What it reads (all plain files under $GOMODCACHE/cache/download): -// -// /@v/.mod each dependency's own go.mod (the edges) -// /@v/.ziphash the h1: module hash (for go.sum) -// -// Limitations (documented, not hidden): -// - The graph is the FULL transitive graph, not the go>=1.17 pruned graph, -// so BuildList may be a superset of `go list -m all` for pruned modules. -// - MVS edges are recorded at the version each module was first reached at; -// this matches `go mod graph` whenever a module appears at a single -// version (the common case), which it does for tidy'd modules. -// - Only version-form `replace` directives in the MAIN module are applied; -// local-path replaces are skipped (marked incomplete). -package modgraph - -import ( - "bytes" - "fmt" - "io" - "os" - "path/filepath" - "strconv" - "strings" - - "golang.org/x/mod/modfile" - "golang.org/x/mod/module" - "golang.org/x/mod/semver" - "golang.org/x/mod/sumdb/dirhash" -) - -// Module is one node of the resolved graph at its selected version. -type Module struct { - Path string - Version string // "" for the main module - GoVersion string // the module's own `go` directive - Main bool - ModuleHash string // h1: zip hash - GoModHash string // h1: go.mod hash -} - -// Edge is a require edge From -> To. -type Edge struct { - FromPath string - FromVersion string - ToPath string - ToVersion string - Indirect bool -} - -// Result is the resolved graph. -type Result struct { - BuildList []Module - Graph []Edge - Complete bool // false if any dependency metadata was missing/unreadable -} - -// Resolve builds the module graph and (pruned) build list for the main module -// described by the raw go.mod content, fetching each dependency's go.mod from -// the given ModSource (local cache, GOPROXY, or a tiered combination). It -// performs no process execution. -// -// The traversal mirrors the go>=1.17 pruned module graph -// (cmd/go/internal/modload/buildlist.go:readModGraph): every loaded module's -// requirements become build-list NODES, but a module's requirements are only -// RECURSED into when that module is unpruned (its go directive is < 1.17). The -// resulting build list matches `go list -m all`. -func Resolve(mainGoMod []byte, src ModSource) (Result, error) { - mf, err := modfile.Parse("go.mod", mainGoMod, nil) - if err != nil { - return Result{}, fmt.Errorf("parse main go.mod: %w", err) - } - - res := Result{Complete: true} - - // Main-module version replacements, keyed by "path" and "path@version". - replace := map[string]module.Version{} - for _, r := range mf.Replace { - if r.New.Version == "" { // local filesystem replace — can't resolve from a module source - res.Complete = false - continue - } - replace[r.Old.Path] = r.New - replace[r.Old.Path+"@"+r.Old.Version] = r.New - } - applyReplace := func(m module.Version) module.Version { - if nv, ok := replace[m.Path+"@"+m.Version]; ok { - return nv - } - if nv, ok := replace[m.Path]; ok { - return nv - } - return m - } - - mainPath := "" - mainGo := "" - if mf.Module != nil { - mainPath = mf.Module.Mod.Path - } - if mf.Go != nil { - mainGo = mf.Go.Version - } - - present := map[string]string{} // path -> MVS-selected version (build-list nodes) - goVersionAt := map[string]string{} // path@version -> go directive - goModBytesAt := map[string][]byte{} // path@version -> go.mod bytes (loaded modules) - loadPath := map[string]bool{} // paths we recurse into - - type pv struct{ path, version string } - var loadQueue []pv - enqueued := map[string]bool{} - enqueueLoad := func(m module.Version) { - k := m.Path + "@" + m.Version - if !enqueued[k] { - enqueued[k] = true - loadQueue = append(loadQueue, pv{m.Path, m.Version}) - } - } - // setNode records a build-list node at its highest seen version. If the - // version of a load-path is raised, re-enqueue it (simple iterative MVS). - setNode := func(m module.Version) { - if v, ok := present[m.Path]; !ok || semver.Compare(m.Version, v) > 0 { - present[m.Path] = m.Version - if loadPath[m.Path] { - enqueueLoad(m) - } - } - } - markLoad := func(m module.Version) { - loadPath[m.Path] = true - enqueueLoad(m) - } - - // Roots: the main module's requirements. - for _, r := range mf.Require { - to := applyReplace(r.Mod) - res.Graph = append(res.Graph, Edge{FromPath: mainPath, FromVersion: "", ToPath: to.Path, ToVersion: to.Version, Indirect: r.Indirect}) - setNode(to) - markLoad(to) - } - - for len(loadQueue) > 0 { - cur := loadQueue[0] - loadQueue = loadQueue[1:] - if present[cur.path] != cur.version { - continue // superseded by a higher selected version - } - key := cur.path + "@" + cur.version - b, ok := src.GoMod(cur.path, cur.version) - if !ok { - res.Complete = false - continue - } - df, err := modfile.Parse(key, b, nil) - if err != nil { - res.Complete = false - continue - } - goModBytesAt[key] = b - goV := "" - if df.Go != nil { - goV = df.Go.Version - } - goVersionAt[key] = goV - unpruned := goUnpruned(goV) - for _, r := range df.Require { - to := applyReplace(r.Mod) - res.Graph = append(res.Graph, Edge{FromPath: cur.path, FromVersion: cur.version, ToPath: to.Path, ToVersion: to.Version, Indirect: r.Indirect}) - setNode(to) // every requirement of a loaded module is a build-list node - if unpruned { - markLoad(to) // recurse only through unpruned (go<1.17) modules - } - } - } - - // Assemble the build list. Each module carries a GoModHash (go.sum records - // a go.mod hash for the whole build list); ModuleHash (the zip hash) is set - // only when the source can provide it without downloading the zip. - res.BuildList = append(res.BuildList, Module{Path: mainPath, Version: "", GoVersion: mainGo, Main: true}) - for path, version := range present { - key := path + "@" + version - m := Module{Path: path, Version: version, GoVersion: goVersionAt[key]} - b, ok := goModBytesAt[key] - if !ok { - // Leaf node (not recursed into): fetch its go.mod for the go - // directive and hash. Best-effort — its absence does not change - // build-list membership. - b, ok = src.GoMod(path, version) - if ok { - if df, e := modfile.Parse(key, b, nil); e == nil && df.Go != nil { - m.GoVersion = df.Go.Version - } - } - } - if ok { - if h, err := goModHashBytes(b, path, version); err == nil { - m.GoModHash = h - } - } - if h, has := src.ZipHash(path, version); has { - m.ModuleHash = h - } - res.BuildList = append(res.BuildList, m) - } - return res, nil -} - -// goUnpruned reports whether a module with the given go directive is UNPRUNED -// (go < 1.17), meaning its transitive requirements are part of the module graph. -// An empty/invalid version is treated as unpruned (pre-1.16 behavior). -func goUnpruned(v string) bool { - if v == "" { - return true - } - parts := strings.SplitN(v, ".", 3) - if len(parts) < 2 { - return true - } - maj, err1 := strconv.Atoi(parts[0]) - min, err2 := strconv.Atoi(parts[1]) - if err1 != nil || err2 != nil { - return true - } - return maj < 1 || (maj == 1 && min < 17) -} - -// readCacheFile reads $download//@v/. -func readCacheFile(download, path, version, suffix string) ([]byte, error) { - ep, err := module.EscapePath(path) - if err != nil { - return nil, err - } - ev, err := module.EscapeVersion(version) - if err != nil { - return nil, err - } - return os.ReadFile(filepath.Join(download, ep, "@v", ev+suffix)) -} - -// readZipHash returns the h1: module hash recorded in the cache `.ziphash`. -func readZipHash(download, path, version string) (string, error) { - b, err := readCacheFile(download, path, version, ".ziphash") - if err != nil { - return "", err - } - h := string(b) - for len(h) > 0 && (h[len(h)-1] == '\n' || h[len(h)-1] == '\r') { - h = h[:len(h)-1] - } - return h, nil -} - -// goModHash computes the h1: hash of a module's go.mod, matching the value go -// records in go.sum (dirhash.Hash1 over the single "@/go.mod"). -// goModHashBytes computes the h1: hash of go.mod content, matching the value go -// records in go.sum (dirhash.Hash1 over the single "@/go.mod"). -func goModHashBytes(b []byte, path, version string) (string, error) { - name := path + "@" + version + "/go.mod" - return dirhash.Hash1([]string{name}, func(string) (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader(b)), nil - }) -} diff --git a/rewrite-go/pkg/parser/modgraph/modgraph_test.go b/rewrite-go/pkg/parser/modgraph/modgraph_test.go deleted file mode 100644 index 273ef885cfb..00000000000 --- a/rewrite-go/pkg/parser/modgraph/modgraph_test.go +++ /dev/null @@ -1,424 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package modgraph - -import ( - goparser "go/parser" - "go/token" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "testing" - - "golang.org/x/mod/modfile" -) - -// TestResolveViaProxy validates that the resolver computes the same build list -// when fetching every dependency go.mod from a real GOPROXY (no module cache -// reads) as the toolchain's own `go list -m all`. This is the production path: -// the proxy fetch is injected (here a direct HTTP client; in the CLI it routes -// through OpenRewrite's HttpSender over RPC). -func TestResolveViaProxy(t *testing.T) { - if testing.Short() { - t.Skip("needs network + the go toolchain") - } - dir := t.TempDir() - write(t, dir, "go.mod", "module example.com/proxytest\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n") - write(t, dir, "main.go", "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n") - runGo(t, dir, "mod", "tidy") - golden := listModVersions(t, dir) - - mainGoMod, err := os.ReadFile(filepath.Join(dir, "go.mod")) - if err != nil { - t.Fatal(err) - } - - httpGet := func(url string) ([]byte, int, error) { - resp, err := http.Get(url) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - return b, resp.StatusCode, nil - } - src := ProxySource(strings.TrimSpace(runGo(t, dir, "env", "GOPROXY")), httpGet) - - res, err := Resolve(mainGoMod, src) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - if !res.Complete { - t.Errorf("expected complete proxy resolution; got Complete=false") - } - - ours := map[string]string{} - for _, m := range res.BuildList { - if !m.Main { - ours[m.Path] = m.Version - } - } - for path, gver := range golden { - if path == "example.com/proxytest" { - continue - } - if over, ok := ours[path]; !ok { - t.Errorf("proxy build list missing module %s (golden %s)", path, gver) - } else if over != gver { - t.Errorf("version mismatch for %s: proxy=%s, go list=%s", path, over, gver) - } - } - for path := range ours { - if _, ok := golden[path]; !ok { - t.Errorf("proxy build list has extra module %s (not in `go list -m all`)", path) - } - } - if !t.Failed() { - t.Logf("OK: proxy-fetched build list (%d modules) matches `go list -m all`", len(ours)) - } -} - -// TestNeededViaProxy is the capstone: with NO module cache, the resolver fetches -// every dependency go.mod AND every needed package's source zip from the real -// GOPROXY, and computes the same direct/indirect require set as `go mod tidy`. -// This is the "clean clone, no go tooling" path the CLI needs. -func TestNeededViaProxy(t *testing.T) { - if testing.Short() { - t.Skip("needs network + the go toolchain") - } - dir := t.TempDir() - write(t, dir, "go.mod", "module example.com/needproxy\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n") - write(t, dir, "main.go", "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n") - runGo(t, dir, "mod", "tidy") - wantDirect, wantIndirect := goldenRequires(t, dir) - mainImports := scanMainImports(t, dir) - - mainGoMod, err := os.ReadFile(filepath.Join(dir, "go.mod")) - if err != nil { - t.Fatal(err) - } - httpGet := func(url string) ([]byte, int, error) { - resp, err := http.Get(url) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - return b, resp.StatusCode, nil - } - src := ProxySource(strings.TrimSpace(runGo(t, dir, "env", "GOPROXY")), httpGet) - - res, err := Resolve(mainGoMod, src) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - rs := NeededModules(mainImports, "example.com/needproxy", res, src, true) - if !rs.Complete { - t.Errorf("expected complete proxy require-set resolution; got Complete=false") - } - if d := diffSet(wantDirect, keys(rs.Direct)); d != "" { - t.Errorf("direct require set mismatch (proxy):\n%s", d) - } - if d := diffSet(wantIndirect, keys(rs.Indirect)); d != "" { - t.Errorf("indirect require set mismatch (proxy):\n%s", d) - } - if !t.Failed() { - t.Logf("OK: proxy-only require set direct=%v indirect=%v matches go mod tidy", keys(rs.Direct), keys(rs.Indirect)) - } -} - -// TestResolveMatchesToolchain validates the pure-Go resolver against the real -// `go` toolchain: the resolver's selected versions must agree with -// `go list -m all`, and every edge `go mod graph` reports must be present in -// the resolver's graph. The toolchain is used only as the GOLDEN here — the -// resolver itself never execs or hits the network. -func TestResolveMatchesToolchain(t *testing.T) { - if testing.Short() { - t.Skip("needs the go toolchain + module cache/network") - } - dir := t.TempDir() - write(t, dir, "go.mod", "module example.com/graphtest\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n") - write(t, dir, "main.go", "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n") - - // Populate the cache + go.sum so `go list -m all` works. - runGo(t, dir, "mod", "tidy") - - gomodcache := strings.TrimSpace(runGo(t, dir, "env", "GOMODCACHE")) - mainGoMod, err := os.ReadFile(filepath.Join(dir, "go.mod")) - if err != nil { - t.Fatal(err) - } - - res, err := Resolve(mainGoMod, CacheSource(gomodcache)) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - if !res.Complete { - t.Errorf("expected complete resolution from a populated cache; got Complete=false") - } - - // 1. Concrete known edge: pyroscope-go pulls klauspost/compress as indirect. - if !hasEdge(res, "github.com/grafana/pyroscope-go", "v1.2.8", "github.com/klauspost/compress", "v1.17.8", true) { - t.Errorf("missing expected edge pyroscope-go@v1.2.8 -> klauspost/compress@v1.17.8 (indirect)") - } - - // 2. Build-list versions must agree with `go list -m all`. - golden := listModVersions(t, dir) - ours := map[string]string{} - for _, m := range res.BuildList { - ours[m.Path] = m.Version - } - for path, gver := range golden { - if path == "example.com/graphtest" { - continue - } - if over, ok := ours[path]; !ok { - t.Errorf("build list missing module %s (golden %s)", path, gver) - } else if over != gver { - t.Errorf("version mismatch for %s: resolver=%s, go list=%s", path, over, gver) - } - } - - // 3. Every edge `go mod graph` reports must be in our graph. - ourEdges := edgeSet(res) - var missing int - for _, g := range graphEdges(t, dir) { - if !ourEdges[g] { - missing++ - if missing <= 10 { - t.Errorf("edge from `go mod graph` not in resolver graph: %s", g) - } - } - } - if missing == 0 { - t.Logf("OK: build list (%d modules) and all `go mod graph` edges reproduced", len(res.BuildList)) - } - - // 4. go.sum material: every build-list module has a go.mod hash; modules - // whose packages are imported (zip downloaded) also have a zip hash. This - // mirrors go.sum, which records a /go.mod hash for the whole build list - // and an h1: zip hash only for modules that are actually built. - for _, m := range res.BuildList { - if m.Main { - continue - } - if !strings.HasPrefix(m.GoModHash, "h1:") { - t.Errorf("module %s@%s missing h1 GoModHash", m.Path, m.Version) - } - if m.ModuleHash != "" && !strings.HasPrefix(m.ModuleHash, "h1:") { - t.Errorf("module %s@%s has malformed ModuleHash %q", m.Path, m.Version, m.ModuleHash) - } - } - // The directly-imported modules must have a zip hash. - for _, p := range []string{"github.com/grafana/pyroscope-go", "golang.org/x/mod"} { - found := false - for _, m := range res.BuildList { - if m.Path == p && strings.HasPrefix(m.ModuleHash, "h1:") { - found = true - } - } - if !found { - t.Errorf("imported module %s should have a zip ModuleHash", p) - } - } - - // 5. The package-import-graph require set must match real `go mod tidy`'s - // go.mod requires exactly (paths + direct/indirect classification). - mainImports := scanMainImports(t, dir) - rs := NeededModules(mainImports, "example.com/graphtest", res, CacheSource(gomodcache), true) - if !rs.Complete { - t.Errorf("expected complete require-set resolution; got Complete=false") - } - wantDirect, wantIndirect := goldenRequires(t, dir) - if d := diffSet(wantDirect, keys(rs.Direct)); d != "" { - t.Errorf("direct require set mismatch:\n%s", d) - } - if d := diffSet(wantIndirect, keys(rs.Indirect)); d != "" { - t.Errorf("indirect require set mismatch:\n%s", d) - } - if t.Failed() { - t.Logf("resolver direct=%v indirect=%v", keys(rs.Direct), keys(rs.Indirect)) - } -} - -// scanMainImports returns the union of import paths across all .go files in -// the main module (mirrors what the recipe scan phase collects). -func scanMainImports(t *testing.T, dir string) []string { - t.Helper() - set := map[string]bool{} - fset := token.NewFileSet() - err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") { - return err - } - f, perr := goparser.ParseFile(fset, path, nil, goparser.ImportsOnly) - if perr != nil { - return nil - } - for _, spec := range f.Imports { - set[strings.Trim(spec.Path.Value, "\"`")] = true - } - return nil - }) - if err != nil { - t.Fatal(err) - } - return keys(setToMap(set)) -} - -// goldenRequires parses the real (post-tidy) go.mod and returns the direct and -// indirect module paths it declares. -func goldenRequires(t *testing.T, dir string) (direct, indirect []string) { - t.Helper() - b, err := os.ReadFile(filepath.Join(dir, "go.mod")) - if err != nil { - t.Fatal(err) - } - mf, err := modfile.Parse("go.mod", b, nil) - if err != nil { - t.Fatal(err) - } - for _, r := range mf.Require { - if r.Indirect { - indirect = append(indirect, r.Mod.Path) - } else { - direct = append(direct, r.Mod.Path) - } - } - return direct, indirect -} - -func keys[V any](m map[string]V) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -func setToMap(s map[string]bool) map[string]bool { return s } - -func diffSet(want, got []string) string { - w := map[string]bool{} - for _, x := range want { - w[x] = true - } - g := map[string]bool{} - for _, x := range got { - g[x] = true - } - var msg []string - for _, x := range want { - if !g[x] { - msg = append(msg, " missing (tidy has, resolver lacks): "+x) - } - } - for _, x := range got { - if !w[x] { - msg = append(msg, " extra (resolver has, tidy lacks): "+x) - } - } - return strings.Join(msg, "\n") -} - -func hasEdge(res Result, fp, fv, tp, tv string, indirect bool) bool { - for _, e := range res.Graph { - if e.FromPath == fp && e.FromVersion == fv && e.ToPath == tp && e.ToVersion == tv && e.Indirect == indirect { - return true - } - } - return false -} - -// edgeSet renders edges in `go mod graph` textual form ("from to", where a -// version-less node is the main module). -func edgeSet(res Result) map[string]bool { - s := map[string]bool{} - for _, e := range res.Graph { - s[node(e.FromPath, e.FromVersion)+" "+node(e.ToPath, e.ToVersion)] = true - } - return s -} - -func node(path, version string) string { - if version == "" { - return path - } - return path + "@" + version -} - -func listModVersions(t *testing.T, dir string) map[string]string { - out := runGo(t, dir, "list", "-m", "-f", "{{.Path}} {{.Version}}", "all") - m := map[string]string{} - for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - f := strings.Fields(line) - if len(f) == 2 { - m[f[0]] = f[1] - } else if len(f) == 1 { - m[f[0]] = "" - } - } - return m -} - -func graphEdges(t *testing.T, dir string) []string { - out := runGo(t, dir, "mod", "graph") - var edges []string - for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - if line == "" { - continue - } - // `go mod graph` emits synthetic `go@x` / `toolchain@x` pseudo-nodes - // for the go-directive requirement; these are not modules. - if f := strings.Fields(line); len(f) == 2 { - if isPseudoNode(f[0]) || isPseudoNode(f[1]) { - continue - } - } - edges = append(edges, line) - } - return edges -} - -func isPseudoNode(n string) bool { - return n == "go" || n == "toolchain" || - strings.HasPrefix(n, "go@") || strings.HasPrefix(n, "toolchain@") -} - -func runGo(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("go", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("go %s: %v\n%s", strings.Join(args, " "), err, out) - } - return string(out) -} - -func write(t *testing.T, dir, name, content string) { - t.Helper() - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} diff --git a/rewrite-go/pkg/parser/modgraph/needed.go b/rewrite-go/pkg/parser/modgraph/needed.go deleted file mode 100644 index af4cfef7a37..00000000000 --- a/rewrite-go/pkg/parser/modgraph/needed.go +++ /dev/null @@ -1,246 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package modgraph - -import ( - "errors" - goparser "go/parser" - "go/token" - "strings" -) - -var errPackageNotFound = errors.New("package source not found") - -// RequireSet is the set of modules `go mod tidy` would write to go.mod, -// classified the way tidy classifies them. It is computed from the package -// import graph, not just the module graph, so it matches go.mod exactly. -type RequireSet struct { - // Direct maps module path -> version for modules that provide a package - // imported by the main module itself. - Direct map[string]string - // Indirect maps module path -> version for modules needed transitively to - // build the main module's packages, but not imported by it directly. - Indirect map[string]string - // Complete is false if any package directory could not be read (e.g. a - // module was not extracted to the cache), making the set best-effort. - Complete bool - // Unresolved lists import paths that mapped to no build-list module. - Unresolved []string - // MissingDirs lists package directories that could not be read. - MissingDirs []string -} - -// NeededModules computes the exact go.mod require set by walking the package -// import graph. It starts from mainImports — the union of import paths across -// ALL of the main module's .go files (including its tests, which tidy counts) -// — and follows imports into dependency packages, reading their imports from -// the extracted module cache. No `go` execution, no network. -// -// Dependency test files are skipped (tidy does not load dep tests for a -// go>=1.17 main module). Build-constraint-excluded files ARE read: tidy keeps -// modules needed by any GOOS/GOARCH, so all platform files count. -// -// separateIndirect selects the go.mod policy: when true (go >= 1.17) the full -// transitive indirect set is recorded; when false (go < 1.17) indirect modules -// that are implied by another required module's go.mod are omitted, matching -// the smaller pre-1.17 require list. -func NeededModules(mainImports []string, mainModulePath string, res Result, src ModSource, separateIndirect bool) RequireSet { - rs := RequireSet{Direct: map[string]string{}, Indirect: map[string]string{}, Complete: true} - - type modver struct{ path, version string } - var mods []modver - for _, m := range res.BuildList { - if m.Main { - continue - } - mods = append(mods, modver{m.Path, m.Version}) - } - // moduleOf returns the longest build-list module path that is a prefix of - // importPath (the module that provides that package), with its version. - moduleOf := func(importPath string) (string, string) { - best, bestVer := "", "" - for _, m := range mods { - if importPath == m.path || strings.HasPrefix(importPath, m.path+"/") { - if len(m.path) > len(best) { - best, bestVer = m.path, m.version - } - } - } - return best, bestVer - } - isLocal := func(importPath string) bool { - return importPath == mainModulePath || strings.HasPrefix(importPath, mainModulePath+"/") - } - - needed := map[string]string{} - direct := map[string]bool{} - visited := map[string]bool{} - var queue []string - - for _, imp := range mainImports { - if isStdlibImport(imp) || isLocal(imp) { - continue - } - m, ver := moduleOf(imp) - if m == "" { - rs.Complete = false - continue - } - direct[m] = true - needed[m] = ver - queue = append(queue, imp) - } - - for len(queue) > 0 { - imp := queue[0] - queue = queue[1:] - if visited[imp] { - continue - } - visited[imp] = true - if isStdlibImport(imp) || isLocal(imp) { - continue - } - m, ver := moduleOf(imp) - if m == "" { - // Import maps to no build-list module. Since the build list is - // authoritative (it mirrors `go list -m all`), this means the - // import is not part of the real build — e.g. an `//go:build - // ignore` generator or a platform the build doesn't select. It is - // not a missing dependency, so record it for diagnostics but do - // not treat resolution as incomplete. - rs.Unresolved = append(rs.Unresolved, imp) - continue - } - needed[m] = ver - - deps, err := packageImports(src, m, ver, imp) - if err != nil { - rs.Complete = false - rs.MissingDirs = append(rs.MissingDirs, imp) - continue - } - for _, dep := range deps { - if isStdlibImport(dep) || isLocal(dep) || visited[dep] { - continue - } - queue = append(queue, dep) - } - } - - // For go < 1.17, omit indirect modules that are implied by another needed - // module's go.mod (their version is already pinned by the graph). go >= 1.17 - // records the full set. - implied := map[string]bool{} - if !separateIndirect { - for _, e := range res.Graph { - if e.FromPath != "" && e.FromPath != mainModulePath && needed[e.FromPath] != "" { - implied[e.ToPath] = true - } - } - } - - for mod, ver := range needed { - switch { - case direct[mod]: - rs.Direct[mod] = ver - case implied[mod]: - // omitted (pre-1.17 implied indirect) - default: - rs.Indirect[mod] = ver - } - } - return rs -} - -// packageImports returns the non-test imports of the package at importPath, -// which lives in module mod@version. Source files come from the ModSource (the -// local cache or a downloaded+extracted module zip). -func packageImports(src ModSource, mod, version, importPath string) ([]string, error) { - files, ok := src.PackageGoFiles(mod, version, importPath) - if !ok { - return nil, errPackageNotFound - } - set := map[string]bool{} - fset := token.NewFileSet() - for name, content := range files { - if strings.HasSuffix(name, "_test.go") { - continue - } - f, err := goparser.ParseFile(fset, name, content, goparser.ImportsOnly) - if err != nil { - continue - } - for _, spec := range f.Imports { - p := strings.Trim(spec.Path.Value, "\"`") - if p != "" { - set[p] = true - } - } - } - out := make([]string, 0, len(set)) - for p := range set { - out = append(out, p) - } - return out, nil -} - -// packageImportsWithTests is like packageImports but returns the package's -// ordinary imports and its test-file imports separately. Used by the pruning- -// completeness pass, which must follow test imports of dependency packages. -func packageImportsWithTests(src ModSource, mod, version, importPath string) (imports, testImports []string, err error) { - files, ok := src.PackageGoFiles(mod, version, importPath) - if !ok { - return nil, nil, errPackageNotFound - } - impSet, testSet := map[string]bool{}, map[string]bool{} - fset := token.NewFileSet() - for name, content := range files { - f, perr := goparser.ParseFile(fset, name, content, goparser.ImportsOnly) - if perr != nil { - continue - } - target := impSet - if strings.HasSuffix(name, "_test.go") { - target = testSet - } - for _, spec := range f.Imports { - p := strings.Trim(spec.Path.Value, "\"`") - if p != "" { - target[p] = true - } - } - } - keys := func(set map[string]bool) []string { - out := make([]string, 0, len(set)) - for p := range set { - out = append(out, p) - } - return out - } - return keys(impSet), keys(testSet), nil -} - -// isStdlibImport reports whether importPath is a standard-library package -// (no dot in its first path segment). Mirrors gofmt/goimports' heuristic. -func isStdlibImport(importPath string) bool { - first := importPath - if i := strings.IndexByte(importPath, '/'); i >= 0 { - first = importPath[:i] - } - return !strings.Contains(first, ".") -} diff --git a/rewrite-go/pkg/parser/modgraph/source.go b/rewrite-go/pkg/parser/modgraph/source.go deleted file mode 100644 index 88aef529b35..00000000000 --- a/rewrite-go/pkg/parser/modgraph/source.go +++ /dev/null @@ -1,394 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package modgraph - -import ( - "archive/zip" - "bytes" - "os" - "path/filepath" - "strings" - "sync" - - "golang.org/x/mod/module" - "golang.org/x/mod/sumdb/dirhash" -) - -// ModSource supplies module metadata (go.mod files, and where available the -// zip hash) for the resolver. It abstracts WHERE the data comes from so the -// resolution algorithm is identical whether the bytes are read from the local -// module cache or fetched from a GOPROXY. -// -// The proxy implementation deliberately does not perform HTTP itself: it calls -// an injected getter, so in production the bytes are fetched through the CLI's -// OpenRewrite HttpSender (via bidirectional RPC), and in tests through a direct -// HTTP client or a fake. -type ModSource interface { - // GoMod returns the go.mod bytes for module path@version, and whether it - // was found. - GoMod(path, version string) ([]byte, bool) - // ZipHash returns the h1: module (zip) hash, and whether it is available. - // Only the local cache can provide this without downloading the zip. - ZipHash(path, version string) (string, bool) - // PackageGoFiles returns the .go source files (keyed by base filename, - // including tests) of the package at importPath within module - // modPath@version. ok is false if the package could not be located. This is - // what gives a clean clone the dependency SOURCES needed to compute the - // package-import graph (and, in future, full type attribution) without any - // Go tooling — the proxy implementation downloads and extracts the module - // zip on demand. - PackageGoFiles(modPath, version, importPath string) (files map[string][]byte, ok bool) -} - -// HTTPGet performs an HTTP GET and returns the body, status code, and error. -// In production this is backed by the CLI's HttpSender over RPC. -type HTTPGet func(url string) (body []byte, status int, err error) - -// CacheSource reads module metadata from a local module cache (the value of -// `go env GOMODCACHE`). -func CacheSource(gomodcache string) ModSource { - return &cacheSource{root: gomodcache, download: filepath.Join(gomodcache, "cache", "download")} -} - -type cacheSource struct { - root string // GOMODCACHE; extracted module dirs live directly under it - download string // GOMODCACHE/cache/download -} - -func (c *cacheSource) GoMod(path, version string) ([]byte, bool) { - b, err := readCacheFile(c.download, path, version, ".mod") - if err != nil { - return nil, false - } - return b, true -} - -func (c *cacheSource) ZipHash(path, version string) (string, bool) { - h, err := readZipHash(c.download, path, version) - if err != nil { - return "", false - } - return h, true -} - -func (c *cacheSource) PackageGoFiles(modPath, version, importPath string) (map[string][]byte, bool) { - ep, err := module.EscapePath(modPath) - if err != nil { - return nil, false - } - ev, err := module.EscapeVersion(version) - if err != nil { - return nil, false - } - // Preferred: the extracted module tree (`$GOMODCACHE/@/...`), - // present when `go` has unzipped the module. - rel := strings.TrimPrefix(strings.TrimPrefix(importPath, modPath), "/") - dir := filepath.Join(c.root, ep+"@"+ev, filepath.FromSlash(rel)) - if entries, err := os.ReadDir(dir); err == nil { - files := map[string][]byte{} - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { - continue - } - if b, err := os.ReadFile(filepath.Join(dir, e.Name())); err == nil { - files[e.Name()] = b - } - } - if len(files) > 0 { - return files, true - } - } - // Fallback: the cached download zip (`cache/download//@v/.zip`), - // which the write-through proxy persists even when `go` has never run, so a - // clean clone can serve dependency sources without any extraction step. - if raw, err := os.ReadFile(filepath.Join(c.download, ep, "@v", ev+".zip")); err == nil { - if entries, err := goFilesFromZip(raw); err == nil { - return packageFilesFromZipEntries(entries, modPath, version, importPath) - } - } - return nil, false -} - -// ProxySource fetches module metadata from one or more GOPROXY base URLs using -// the injected get function. goproxy is a GOPROXY-style list (comma/pipe -// separated); "off"/"direct"/"none" entries and the VCS fallback are skipped -// (this source only speaks the proxy protocol). If goproxy is empty, -// https://proxy.golang.org is used. -func ProxySource(goproxy string, get HTTPGet) ModSource { - return newProxySource(goproxy, get, "") -} - -// ProxyWriteThroughSource is like ProxySource but additionally persists every -// successfully fetched .mod and .zip (plus the computed .ziphash) into the -// standard Go module download cache at gomodcache/cache/download, using the -// exact on-disk layout `go mod download` produces. The first fetch of a -// module@version costs a network round-trip; thereafter CacheSource (and the -// real `go` toolchain) serve it offline. Writes are atomic (temp-file+rename), -// so concurrent readers never observe a partial file. If gomodcache is empty it -// behaves exactly like ProxySource (no persistence). -func ProxyWriteThroughSource(goproxy, gomodcache string, get HTTPGet) ModSource { - cacheDir := "" - if gomodcache != "" { - cacheDir = filepath.Join(gomodcache, "cache", "download") - } - return newProxySource(goproxy, get, cacheDir) -} - -func newProxySource(goproxy string, get HTTPGet, cacheDir string) *proxySource { - var bases []string - for _, p := range strings.FieldsFunc(goproxy, func(r rune) bool { return r == ',' || r == '|' }) { - p = strings.TrimSpace(p) - if p == "" || p == "off" || p == "direct" || p == "none" { - continue - } - bases = append(bases, strings.TrimRight(p, "/")) - } - if len(bases) == 0 { - bases = []string{"https://proxy.golang.org"} - } - return &proxySource{bases: bases, get: get, cacheDir: cacheDir, zips: map[string]map[string][]byte{}} -} - -type proxySource struct { - bases []string - get HTTPGet - // cacheDir, when non-empty, is GOMODCACHE/cache/download: fetched .mod/.zip - // are written through to it in the standard layout. - cacheDir string - mu sync.Mutex - // zips caches the extracted contents of a module zip, keyed by - // "modPath@version" -> (full zip entry path -> bytes). A nil value records - // a failed download so it is not retried. - zips map[string]map[string][]byte -} - -func (p *proxySource) GoMod(path, version string) ([]byte, bool) { - ep, err := module.EscapePath(path) - if err != nil { - return nil, false - } - ev, err := module.EscapeVersion(version) - if err != nil { - return nil, false - } - suffix := "/" + ep + "/@v/" + ev + ".mod" - for _, base := range p.bases { - body, status, err := p.get(base + suffix) - if err == nil && status == 200 && len(body) > 0 { - p.persist(ep, ev, ".mod", body) - return body, true - } - } - return nil, false -} - -// ZipHash is unavailable from the proxy without downloading and hashing the -// module zip; go.sum generation is handled separately. -func (p *proxySource) ZipHash(path, version string) (string, bool) { - return "", false -} - -func (p *proxySource) PackageGoFiles(modPath, version, importPath string) (map[string][]byte, bool) { - entries, ok := p.moduleZip(modPath, version) - if !ok { - return nil, false - } - return packageFilesFromZipEntries(entries, modPath, version, importPath) -} - -// moduleZip downloads (once) and extracts the module zip for modPath@version, -// returning a map of zip-entry path -> contents. -func (p *proxySource) moduleZip(modPath, version string) (map[string][]byte, bool) { - key := modPath + "@" + version - p.mu.Lock() - if cached, seen := p.zips[key]; seen { - p.mu.Unlock() - return cached, cached != nil - } - p.mu.Unlock() - - entries := p.downloadZip(modPath, version) - p.mu.Lock() - p.zips[key] = entries // nil on failure — caches the negative result - p.mu.Unlock() - return entries, entries != nil -} - -func (p *proxySource) downloadZip(modPath, version string) map[string][]byte { - ep, err := module.EscapePath(modPath) - if err != nil { - return nil - } - ev, err := module.EscapeVersion(version) - if err != nil { - return nil - } - suffix := "/" + ep + "/@v/" + ev + ".zip" - var raw []byte - for _, base := range p.bases { - body, status, err := p.get(base + suffix) - if err == nil && status == 200 && len(body) > 0 { - raw = body - break - } - } - if raw == nil { - return nil - } - entries, err := goFilesFromZip(raw) - if err != nil { - return nil - } - // Write through to the standard cache only after a successful parse, so we - // never persist a corrupt/truncated download. - p.persistZip(ep, ev, raw) - return entries -} - -// persist atomically writes content to GOMODCACHE/cache/download//@v/ -// when a write-through cache dir is configured. Best-effort: cache write -// failures never affect resolution (the bytes are already in hand). -func (p *proxySource) persist(ep, ev, suffix string, content []byte) { - if p.cacheDir == "" { - return - } - _ = atomicWriteFile(filepath.Join(p.cacheDir, ep, "@v", ev+suffix), content) -} - -// persistZip writes the raw module zip and its computed h1: .ziphash (the value -// `go` records in go.sum and the cache .ziphash file) into the standard cache. -func (p *proxySource) persistZip(ep, ev string, raw []byte) { - if p.cacheDir == "" { - return - } - zipPath := filepath.Join(p.cacheDir, ep, "@v", ev+".zip") - if err := atomicWriteFile(zipPath, raw); err != nil { - return - } - if h, err := dirhash.HashZip(zipPath, dirhash.Hash1); err == nil { - _ = atomicWriteFile(filepath.Join(p.cacheDir, ep, "@v", ev+".ziphash"), []byte(h)) - } -} - -// goFilesFromZip extracts every .go file from a module zip, keyed by its full -// "@/" entry name. -func goFilesFromZip(raw []byte) (map[string][]byte, error) { - zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) - if err != nil { - return nil, err - } - entries := map[string][]byte{} - for _, f := range zr.File { - if f.FileInfo().IsDir() || !strings.HasSuffix(f.Name, ".go") { - continue - } - rc, err := f.Open() - if err != nil { - continue - } - var buf bytes.Buffer - _, _ = buf.ReadFrom(rc) - rc.Close() - entries[f.Name] = buf.Bytes() - } - return entries, nil -} - -// packageFilesFromZipEntries filters extracted zip entries down to the .go files -// of exactly one package directory (importPath within modPath@version). -func packageFilesFromZipEntries(entries map[string][]byte, modPath, version, importPath string) (map[string][]byte, bool) { - rel := strings.TrimPrefix(strings.TrimPrefix(importPath, modPath), "/") - prefix := modPath + "@" + version + "/" - if rel != "" { - prefix += rel + "/" - } - files := map[string][]byte{} - for name, content := range entries { - if !strings.HasPrefix(name, prefix) { - continue - } - tail := name[len(prefix):] - if strings.Contains(tail, "/") || !strings.HasSuffix(tail, ".go") { - continue // a deeper subpackage or non-go file - } - files[tail] = content - } - return files, len(files) > 0 -} - -// atomicWriteFile writes data to path via a temp file + rename, creating parent -// dirs as needed, so a concurrent reader never observes a partial file. -func atomicWriteFile(path string, data []byte) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - tmp, err := os.CreateTemp(dir, ".tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - if _, err := tmp.Write(data); err != nil { - tmp.Close() - os.Remove(tmpName) - return err - } - if err := tmp.Close(); err != nil { - os.Remove(tmpName) - return err - } - if err := os.Rename(tmpName, path); err != nil { - os.Remove(tmpName) - return err - } - return nil -} - -// TieredSource tries each source in order, returning the first hit. Use it to -// prefer the local cache and fall back to the proxy. -func TieredSource(sources ...ModSource) ModSource { - return &tieredSource{sources: sources} -} - -type tieredSource struct{ sources []ModSource } - -func (t *tieredSource) GoMod(path, version string) ([]byte, bool) { - for _, s := range t.sources { - if b, ok := s.GoMod(path, version); ok { - return b, true - } - } - return nil, false -} - -func (t *tieredSource) ZipHash(path, version string) (string, bool) { - for _, s := range t.sources { - if h, ok := s.ZipHash(path, version); ok { - return h, true - } - } - return "", false -} - -func (t *tieredSource) PackageGoFiles(modPath, version, importPath string) (map[string][]byte, bool) { - for _, s := range t.sources { - if files, ok := s.PackageGoFiles(modPath, version, importPath); ok { - return files, true - } - } - return nil, false -} diff --git a/rewrite-go/pkg/parser/modgraph/tidy.go b/rewrite-go/pkg/parser/modgraph/tidy.go deleted file mode 100644 index b7bf579c588..00000000000 --- a/rewrite-go/pkg/parser/modgraph/tidy.go +++ /dev/null @@ -1,313 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package modgraph - -import ( - "strings" - - "golang.org/x/mod/modfile" - "golang.org/x/mod/module" - "golang.org/x/mod/semver" -) - -// TidyRequireSet computes the exact go.mod require set `go mod tidy` would write, -// including the go 1.17+ pruning-completeness roots that NeededModules alone does -// not capture. -// -// NeededModules gives the modules that PROVIDE a package in `all` (direct + -// import-reachable indirect). For a go>=1.17 main module, `go mod tidy` also adds -// an indirect root for every module that is reachable from `all` through imports -// OR TESTS but whose version the pruned module graph would UNDER-SELECT — i.e. a -// test-transitive dependency whose required version is higher than any root's -// pruned go.mod implies (e.g. kr/text via gopkg.in/check.v1, go.uber.org/mock via -// a dependency's test). This mirrors cmd/go/internal/modload.tidyPrunedRoots: -// walk imports+tests outward from `all`, and promote a module to an explicit root -// whenever Selected(path) < loadedVersion(path). -// -// The version gate is essential: it adds mock/kr/text (genuinely under-selected) -// while leaving testify-style clusters out when the pruned graph already selects -// them correctly. For go<1.17 (no pruning) or an incomplete base resolution it -// returns NeededModules unchanged. -func TidyRequireSet(mainImports []string, mainModulePath, mainGoMod string, res Result, src ModSource, separateIndirect bool) RequireSet { - base := NeededModules(mainImports, mainModulePath, res, src, separateIndirect) - if !separateIndirect || !base.Complete { - return base - } - - // loaded[path] = the version each module is selected at in the full build - // list (go's `m.Version` — the version a package's module is loaded at). - loaded := map[string]string{} - var mods []modVer - for _, m := range res.BuildList { - if !m.Main { - loaded[m.Path] = m.Version - mods = append(mods, modVer{m.Path, m.Version}) - } - } - moduleOf := func(importPath string) (string, string) { - best, bestVer := "", "" - for _, m := range mods { - if importPath == m.path || strings.HasPrefix(importPath, m.path+"/") { - if len(m.path) > len(best) { - best, bestVer = m.path, m.version - } - } - } - return best, bestVer - } - isLocal := func(importPath string) bool { - return importPath == mainModulePath || strings.HasPrefix(importPath, mainModulePath+"/") - } - - // Current root set: everything NeededModules already requires. - roots := map[string]string{} - for p, v := range base.Direct { - roots[p] = v - } - for p, v := range base.Indirect { - roots[p] = v - } - - // Build a requirement index once: every module version's direct requires, - // seeded for free from the already-resolved graph and lazily fetching only - // the few promoted roots that pruning left unloaded. Pruned MVS then runs - // entirely in memory — no per-iteration re-resolution. - idx := newReqIndex(res, mainGoMod, src) - - // Walk the package import graph FRONTIER BY FRONTIER, by increasing import- - // stack depth, mirroring cmd/go/internal/modload.tidyPrunedRoots. At each - // frontier we recompute the pruned selection under the roots accumulated so - // far, then promote any frontier module the pruned graph under-selects. This - // ordering is essential: promoting a shallow module (e.g. kr/pretty) pins its - // deeper requirements (kr/text) BEFORE they are examined, so they are not - // wrongly promoted. A package's TEST imports are deferred one frontier deeper - // (go models this as a separate `.test` node) so test-transitive deps - // sort below ordinary ones. - type qitem struct { - path string - isTest bool - } - queued := map[string]bool{} - var queue []qitem - enq := func(path string, isTest bool) { - if isStdlibImport(path) || isLocal(path) { - return - } - k := path - if isTest { - k += "\x00t" - } - if !queued[k] { - queued[k] = true - queue = append(queue, qitem{path, isTest}) - } - } - for _, imp := range mainImports { - enq(imp, false) - } - - for len(queue) > 0 { - sel := prunedSelectInMemory(roots, idx) - frontier := queue - queue = nil - for _, it := range frontier { - mod, ver := moduleOf(it.path) - if mod == "" { - continue - } - imports, testImports, err := packageImportsWithTests(src, mod, ver, it.path) - if err == nil { - if it.isTest { - for _, d := range testImports { - enq(d, false) - } - } else { - for _, d := range imports { - enq(d, false) - } - enq(it.path, true) // the package's test node, one frontier deeper - } - } - if _, isRoot := roots[mod]; !isRoot { - want := loaded[mod] - have := sel[mod] - if want != "" && (have == "" || semver.Compare(have, want) < 0) { - roots[mod] = want - } - } - } - } - - // Reclassify: direct stays direct; every other root is indirect. - out := RequireSet{ - Direct: map[string]string{}, - Indirect: map[string]string{}, - Complete: true, - Unresolved: base.Unresolved, - MissingDirs: base.MissingDirs, - } - for p, v := range roots { - if _, isDirect := base.Direct[p]; isDirect { - out.Direct[p] = v - } else { - out.Indirect[p] = v - } - } - return out -} - -// reqIndex memoizes each module version's direct requirements (post-replace) and -// go directive. It is seeded for free from an already-resolved graph (whose edges -// cover every LOADED module) and lazily fetches the go.mod of any module that -// pruning left unloaded — only ever the handful of pruned modules promoted to -// roots. This lets prunedSelectInMemory run a pruned MVS without re-resolving. -type reqIndex struct { - edges map[string][]module.Version // "path@version" -> requires (post-replace) - gover map[string]string // "path@version" -> go directive - known map[string]bool // keys whose edges are fully populated - replace map[string]module.Version // main module's version replacements - src ModSource -} - -func newReqIndex(res Result, mainGoMod string, src ModSource) *reqIndex { - idx := &reqIndex{ - edges: map[string][]module.Version{}, - gover: map[string]string{}, - known: map[string]bool{}, - replace: map[string]module.Version{}, - src: src, - } - // Main module's version replacements (local-path replaces are skipped; they - // already mark the resolution incomplete upstream). - if mf, err := modfile.Parse("go.mod", []byte(mainGoMod), nil); err == nil { - for _, r := range mf.Replace { - if r.New.Version == "" { - continue - } - idx.replace[r.Old.Path] = r.New - idx.replace[r.Old.Path+"@"+r.Old.Version] = r.New - } - } - // Seed edges from the resolved graph. Every module that was LOADED contributes - // all of its require edges here, so its key is fully known. - for _, e := range res.Graph { - key := e.FromPath + "@" + e.FromVersion - idx.edges[key] = append(idx.edges[key], module.Version{Path: e.ToPath, Version: e.ToVersion}) - idx.known[key] = true - } - for _, m := range res.BuildList { - idx.gover[m.Path+"@"+m.Version] = m.GoVersion - } - return idx -} - -func (idx *reqIndex) applyReplace(m module.Version) module.Version { - if nv, ok := idx.replace[m.Path+"@"+m.Version]; ok { - return nv - } - if nv, ok := idx.replace[m.Path]; ok { - return nv - } - return m -} - -// requires returns module path@version's direct requirements and go directive, -// fetching and memoizing the go.mod when not already seeded. -func (idx *reqIndex) requires(path, version string) ([]module.Version, string) { - key := path + "@" + version - if idx.known[key] { - return idx.edges[key], idx.gover[key] - } - idx.known[key] = true // memoize even on miss, so a failed fetch isn't retried - b, ok := idx.src.GoMod(path, version) - if !ok { - return nil, idx.gover[key] - } - df, err := modfile.Parse(key, b, nil) - if err != nil { - return nil, idx.gover[key] - } - if df.Go != nil { - idx.gover[key] = df.Go.Version - } - reqs := make([]module.Version, 0, len(df.Require)) - for _, r := range df.Require { - reqs = append(reqs, idx.applyReplace(r.Mod)) - } - idx.edges[key] = reqs - return reqs, idx.gover[key] -} - -// prunedSelectInMemory computes the MVS-selected version of every module under the -// go>=1.17 PRUNED module graph rooted at the given root set, reading requirements -// from idx. It mirrors Resolve's traversal exactly: every loaded module's requires -// become build-list nodes, but a module's requires are recursed into only when the -// module is unpruned (its go directive is < 1.17). -func prunedSelectInMemory(roots map[string]string, idx *reqIndex) map[string]string { - present := map[string]string{} // path -> selected version - loadPath := map[string]bool{} // paths we recurse into - enqueued := map[string]bool{} - type pv struct{ path, version string } - var queue []pv - - enqueue := func(m module.Version) { - k := m.Path + "@" + m.Version - if !enqueued[k] { - enqueued[k] = true - queue = append(queue, pv{m.Path, m.Version}) - } - } - setNode := func(m module.Version) { - if v, ok := present[m.Path]; !ok || semver.Compare(m.Version, v) > 0 { - present[m.Path] = m.Version - if loadPath[m.Path] { - enqueue(m) - } - } - } - markLoad := func(m module.Version) { - loadPath[m.Path] = true - enqueue(m) - } - - // Roots = the synthetic main module's requirements. - for path, ver := range roots { - m := module.Version{Path: path, Version: ver} - setNode(m) - markLoad(m) - } - - for len(queue) > 0 { - cur := queue[0] - queue = queue[1:] - if present[cur.path] != cur.version { - continue // superseded by a higher selected version - } - reqs, goV := idx.requires(cur.path, cur.version) - unpruned := goUnpruned(goV) - for _, req := range reqs { - setNode(req) - if unpruned { - markLoad(req) - } - } - } - return present -} - -// modVer is a build-list module path at its selected version. -type modVer struct{ path, version string } diff --git a/rewrite-go/pkg/parser/modgraph/tidy_test.go b/rewrite-go/pkg/parser/modgraph/tidy_test.go deleted file mode 100644 index 01863f4caa7..00000000000 --- a/rewrite-go/pkg/parser/modgraph/tidy_test.go +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package modgraph - -import ( - "io" - "net/http" - "os" - "path/filepath" - "strings" - "testing" -) - -// TestTidyRequireSetViaProxy validates the full TidyRequireSet path (NeededModules -// + go>=1.17 pruning-completeness roots) against real `go mod tidy`, fetching -// everything from the proxy. Each case asserts the computed direct/indirect set -// EXACTLY matches go.mod — proving the pruning pass adds genuinely-needed -// test-transitive roots without over-including the test clusters that the version -// gate must exclude. -func TestTidyRequireSetViaProxy(t *testing.T) { - if testing.Short() { - t.Skip("needs network + the go toolchain") - } - cases := []struct { - name, modPath, goMod, mainGo, testGo string - }{ - { - // No pruning-completeness extras: indirect == import-reachable only. - // Guards against over-inclusion through the full Tidy path. - name: "no_extras", - modPath: "example.com/noextras", - goMod: "module example.com/noextras\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n", - mainGo: "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n", - }, - { - // testify pulls yaml.v3, whose TEST imports gopkg.in/check.v1 -> - // kr/text: a classic pruning-completeness indirect that import-only - // resolution misses but `go mod tidy` records. - name: "test_transitive", - modPath: "example.com/testtrans", - goMod: "module example.com/testtrans\n\ngo 1.25.0\n\nrequire github.com/stretchr/testify v1.9.0\n", - mainGo: "package main\n\nimport _ \"github.com/stretchr/testify/assert\"\n\nfunc main() {}\n", - }, - { - // gin genuinely EXERCISES the pruning-completeness promotion: it adds - // kr/text (via gopkg.in/check.v1) and go.uber.org/mock (via a - // dependency package's test) as indirect roots — modules under- - // selected by the pruned graph that the in-memory MVS must promote. - name: "gin_pruning_completeness", - modPath: "example.com/ginapp", - goMod: "module example.com/ginapp\n\ngo 1.25.0\n\nrequire github.com/gin-gonic/gin v1.10.0\n", - mainGo: "package main\n\nimport _ \"github.com/gin-gonic/gin\"\n\nfunc main() {}\n", - }, - { - // conc shape: testify is imported by a TEST of the main module (not - // main code), with the older testify v1.8.1. `go mod tidy` does NOT - // record kr/text here (the pruned graph already selects it correctly), - // so the version gate must NOT over-promote it. Guards the conc 0/1 - // regression. - name: "test_in_test_file", - modPath: "example.com/conctest", - goMod: "module example.com/conctest\n\ngo 1.20\n\nrequire github.com/stretchr/testify v1.8.1\n", - mainGo: "package conctest\n", - testGo: "package conctest\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestX(t *testing.T) { assert.Equal(t, 1, 1) }\n", - }, - { - // The exact conc go.mod, which exercises the DEPTH-ORDERING rule: - // check.v1 -> kr/pretty -> kr/text. `go mod tidy` promotes kr/pretty - // (under-selected) but NOT kr/text (kr/pretty's go.mod pins it once - // kr/pretty is a root). A non-frontier-ordered pass over-promotes - // kr/text; this guards that the BFS pins it first. - name: "conc_depth_ordering", - modPath: "github.com/sourcegraph/conc", - goMod: "module github.com/sourcegraph/conc\n\ngo 1.20\n\nrequire github.com/stretchr/testify v1.8.1\n\nrequire (\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/kr/pretty v0.3.0 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/rogpeppe/go-internal v1.9.0 // indirect\n\tgopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n", - mainGo: "package conc\n", - testGo: "package conc\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestX(t *testing.T) { assert.Equal(t, 1, 1) }\n", - }, - } - - httpGet := func(url string) ([]byte, int, error) { - resp, err := http.Get(url) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - return b, resp.StatusCode, nil - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - write(t, dir, "go.mod", tc.goMod) - write(t, dir, "main.go", tc.mainGo) - if tc.testGo != "" { - write(t, dir, "main_test.go", tc.testGo) - } - runGo(t, dir, "mod", "tidy") - wantDirect, wantIndirect := goldenRequires(t, dir) - mainImports := scanMainImports(t, dir) - - tidied, err := os.ReadFile(filepath.Join(dir, "go.mod")) - if err != nil { - t.Fatal(err) - } - src := ProxySource(strings.TrimSpace(runGo(t, dir, "env", "GOPROXY")), httpGet) - res, err := Resolve(tidied, src) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - rs := TidyRequireSet(mainImports, tc.modPath, string(tidied), res, src, true) - if !rs.Complete { - t.Fatalf("expected complete TidyRequireSet; got Complete=false") - } - if d := diffSet(wantDirect, keys(rs.Direct)); d != "" { - t.Errorf("direct mismatch:\n%s", d) - } - if d := diffSet(wantIndirect, keys(rs.Indirect)); d != "" { - t.Errorf("indirect mismatch:\n%s", d) - } - if !t.Failed() { - t.Logf("OK: TidyRequireSet indirect=%v matches go mod tidy", keys(rs.Indirect)) - } - }) - } -} diff --git a/rewrite-go/pkg/parser/modgraph/writethrough_test.go b/rewrite-go/pkg/parser/modgraph/writethrough_test.go deleted file mode 100644 index 674444de10e..00000000000 --- a/rewrite-go/pkg/parser/modgraph/writethrough_test.go +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package modgraph - -import ( - "archive/zip" - "bytes" - "os" - "path/filepath" - "strings" - "testing" -) - -// fakeZip builds a minimal but valid module zip: every entry is prefixed -// "@/" as the module proxy requires. -func fakeZip(t *testing.T, modPath, version string, files map[string]string) []byte { - t.Helper() - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - for name, content := range files { - w, err := zw.Create(modPath + "@" + version + "/" + name) - if err != nil { - t.Fatal(err) - } - if _, err := w.Write([]byte(content)); err != nil { - t.Fatal(err) - } - } - if err := zw.Close(); err != nil { - t.Fatal(err) - } - return buf.Bytes() -} - -// TestWriteThroughPersistsStandardCacheLayout verifies that fetching a module's -// .mod and .zip through ProxyWriteThroughSource persists them (plus a computed -// h1: .ziphash) into the standard $GOMODCACHE/cache/download layout, and that a -// fresh CacheSource pointed at that directory then serves the same data offline -// — including package .go files via the zip fallback (no extraction needed). -func TestWriteThroughPersistsStandardCacheLayout(t *testing.T) { - const modPath = "example.com/dep" - const version = "v1.2.3" - goMod := []byte("module example.com/dep\n\ngo 1.21\n") - zipBytes := fakeZip(t, modPath, version, map[string]string{ - "pkg/util.go": "package util\n\nimport _ \"example.com/other\"\n", - "go.mod": string(goMod), - }) - - gomodcache := t.TempDir() - var fetched []string - get := func(url string) ([]byte, int, error) { - fetched = append(fetched, url) - switch { - case strings.HasSuffix(url, "/@v/"+version+".mod"): - return goMod, 200, nil - case strings.HasSuffix(url, "/@v/"+version+".zip"): - return zipBytes, 200, nil - } - return nil, 404, nil - } - - src := ProxyWriteThroughSource("https://proxy.example", gomodcache, get) - - // 1. Fetch .mod and package files through the proxy (warming the cache). - if b, ok := src.GoMod(modPath, version); !ok || !bytes.Equal(b, goMod) { - t.Fatalf("proxy GoMod: ok=%v bytes=%q", ok, b) - } - files, ok := src.PackageGoFiles(modPath, version, modPath+"/pkg") - if !ok || len(files) != 1 || !strings.Contains(string(files["util.go"]), "package util") { - t.Fatalf("proxy PackageGoFiles: ok=%v files=%v", ok, files) - } - - // 2. The standard cache layout must now hold .mod, .zip, and .ziphash. - dl := filepath.Join(gomodcache, "cache", "download", modPath, "@v") - for _, suffix := range []string{".mod", ".zip", ".ziphash"} { - if _, err := os.Stat(filepath.Join(dl, version+suffix)); err != nil { - t.Errorf("expected cached %s in standard layout: %v", version+suffix, err) - } - } - zh, err := os.ReadFile(filepath.Join(dl, version+".ziphash")) - if err != nil || !strings.HasPrefix(string(zh), "h1:") { - t.Errorf("ziphash not a valid h1: hash: %q (err=%v)", zh, err) - } - - // 3. A fresh CacheSource (no proxy) must serve everything offline. - cache := CacheSource(gomodcache) - if b, ok := cache.GoMod(modPath, version); !ok || !bytes.Equal(b, goMod) { - t.Errorf("cache GoMod after write-through: ok=%v", ok) - } - if h, ok := cache.ZipHash(modPath, version); !ok || h != strings.TrimSpace(string(zh)) { - t.Errorf("cache ZipHash after write-through: ok=%v h=%q want %q", ok, h, zh) - } - cfiles, ok := cache.PackageGoFiles(modPath, version, modPath+"/pkg") - if !ok || !strings.Contains(string(cfiles["util.go"]), `import _ "example.com/other"`) { - t.Errorf("cache PackageGoFiles via zip fallback: ok=%v files=%v", ok, cfiles) - } -} - -// TestProxySourceWithoutCacheDoesNotPersist guards the opt-in: plain ProxySource -// (no gomodcache) must not write anything to disk. -func TestProxySourceWithoutCacheDoesNotPersist(t *testing.T) { - const modPath = "example.com/dep" - const version = "v0.1.0" - goMod := []byte("module example.com/dep\n\ngo 1.21\n") - gomodcache := t.TempDir() - get := func(url string) ([]byte, int, error) { - if strings.HasSuffix(url, ".mod") { - return goMod, 200, nil - } - return nil, 404, nil - } - // Plain ProxySource ignores the cache dir entirely. - src := ProxySource("https://proxy.example", get) - if _, ok := src.GoMod(modPath, version); !ok { - t.Fatal("GoMod should succeed") - } - dl := filepath.Join(gomodcache, "cache", "download") - if entries, _ := os.ReadDir(dl); len(entries) != 0 { - t.Errorf("plain ProxySource must not persist; found %d entries", len(entries)) - } -} diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go index c2169414bf5..83598f834af 100644 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go +++ b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go @@ -23,7 +23,6 @@ import ( "github.com/google/uuid" - "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe/golang/internal" @@ -246,14 +245,6 @@ func (v *goModTidyEditor) VisitGoMod(gm *golang.GoMod, p any) java.Tree { separateIndirect := goVersionAtLeast(gm, 1, 17) - // When the go.mod carries a fully-resolved GoResolutionResult, its Requires - // list is the authoritative require set the package-import graph computed at - // parse time (already pruned of unused modules, with missing ones added and - // // indirect classified). Otherwise fall back to the LST-only Phase 1: - // re-mark and sort the existing requires from the import scan. - res := GetResolutionResult(gm) - authoritative := res != nil && res.GraphComplete && len(res.Requires) > 0 - // Strip the existing require statements, remembering where the first one // was and its leading whitespace. firstIdx := -1 @@ -293,21 +284,14 @@ func (v *goModTidyEditor) VisitGoMod(gm *golang.GoMod, p any) java.Tree { } // Determine the entries to emit, in priority order: - // 1. Compute the exact tidy require set NOW from the resolved module graph, - // the scanned imports, and a ModSource (cache + write-through proxy). - // This is the production path. - // 2. Fall back to the require set the marker carries from parse-time - // resolution, used when (1) cannot complete at recipe time but parse time - // did resolve the full graph. - // 3. LST-only: re-mark the declared requires by direct-import and sort them, - // preserving the existing set (the offline / no-ModSource degradation). + // 1. Compute the exact tidy require set NOW via the Java resolver (the + // scanned imports + the current go.mod, with dependency metadata fetched + // on the host through the CLI HttpSender). This is the production path. + // 2. LST-only: re-mark the declared requires by direct-import and sort them, + // preserving the existing set (the offline / no-resolver degradation). var entries []reqEntry - if computed, ok := v.computeTidySet(gm, res, separateIndirect, p); ok { + if computed, ok := v.computeTidySet(gm, separateIndirect, p); ok { entries = computed - } else if authoritative { - for _, r := range res.Requires { - entries = append(entries, reqEntry{r.ModulePath, r.Version, r.Indirect}) - } } else { direct := v.directModules() for _, e := range collected { @@ -471,53 +455,30 @@ func headerInsertIndex(stmts []java.RightPadded[golang.GoModStatement]) int { return idx } -// computeTidySet computes the exact go.mod require set at recipe time from the -// resolved module graph, the imports scanned from the project's .go files, and -// the dependency ModSource installed in the ExecutionContext (cache + write- -// through GOPROXY via the CLI HttpSender). +// computeTidySet computes the exact go.mod require set at recipe time by +// delegating to the pure-Java module resolver over RPC: it sends the current +// go.mod text, the imports scanned from the project's .go files, and the module +// path, and receives back the direct/indirect require set. All dependency +// metadata (go.mod/zip) is fetched on the host through the CLI HttpSender, so no +// network egress originates from this peer. // -// The graph is taken from the parse-time marker when it is complete; otherwise -// it is re-resolved NOW against the same source. This matters because parse-time -// resolution may be cold (cache-only / offline) while the recipe — which is -// explicitly editing dependencies — is allowed to reach the network and warm -// the shared cache. Without this, a cold parse would permanently pin the recipe -// to the incomplete LST-only fallback even with the network available. -// -// Returns ok=false when the source is absent or resolution still cannot complete -// (truly offline + cold cache, or a private module), so the caller falls back to -// the marker's authoritative set (tests) or the LST-only set, which PRESERVES -// the existing require/// indirect block rather than dropping unconfirmed deps. -func (v *goModTidyEditor) computeTidySet(gm *golang.GoMod, res *golang.GoResolutionResult, separateIndirect bool, p any) ([]reqEntry, bool) { +// Returns ok=false when no resolver is installed (running outside the CLI) or +// resolution could not complete (truly offline + cold cache, or a private +// module), so the caller falls back to the LST-only set, which PRESERVES the +// existing require/// indirect block rather than dropping unconfirmed deps. +func (v *goModTidyEditor) computeTidySet(gm *golang.GoMod, separateIndirect bool, p any) ([]reqEntry, bool) { ctx, ok := p.(*recipe.ExecutionContext) if !ok { return nil, false } - src := ModSourceFrom(ctx) - if src == nil { + resolve := TidyResolverFrom(ctx) + if resolve == nil { return nil, false } - // Obtain a module graph to walk: prefer the parse-time graph when complete, - // else re-resolve now against the (network-backed, write-through) source. content := printer.PrintGoMod(gm) - var graph modgraph.Result - if res != nil && res.GraphComplete && len(res.BuildList) > 0 { - graph = modgraph.FromMarker(*res) - } else { - r, err := modgraph.Resolve([]byte(content), src) - if err != nil || !r.Complete || len(r.BuildList) == 0 { - return nil, false - } - graph = r - } - - mainImports := v.curImports - // TidyRequireSet = NeededModules (import-reachable) + the go>=1.17 pruning- - // completeness roots (test-transitive indirect deps under-selected by the - // pruned graph). The latter requires the go.mod text for synthetic re- - // resolution; it no-ops for go<1.17. - rs := modgraph.TidyRequireSet(mainImports, v.curModulePath, content, graph, src, separateIndirect) - if !rs.Complete { + rs, ok := resolve(content, v.curImports, v.curModulePath, separateIndirect) + if !ok || !rs.Complete { return nil, false } diff --git a/rewrite-go/pkg/recipe/golang/module_resolution.go b/rewrite-go/pkg/recipe/golang/module_resolution.go index 1df45107f87..671283ee08a 100644 --- a/rewrite-go/pkg/recipe/golang/module_resolution.go +++ b/rewrite-go/pkg/recipe/golang/module_resolution.go @@ -18,32 +18,47 @@ package golang import ( goparser "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" - "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" ) -// modSourceKey is the ExecutionContext key under which the RPC server installs -// the dependency-resolution ModSource (cache + GOPROXY). The proxy tier routes -// HTTP through the CLI's HttpSender, so recipe-time resolution may hit the -// network exactly as parse-time resolution does. -const modSourceKey = "org.openrewrite.golang.modgraph.source" +// TidyRequireSet is the result of the `go mod tidy` require-set computation, +// which is performed by the pure-Java module resolver on the host side. The Go +// recipe sends the go.mod and scanned imports over RPC and receives this back. +type TidyRequireSet struct { + // Direct maps module path -> version for modules imported by the main module. + Direct map[string]string + // Indirect maps module path -> version for transitively-needed modules. + Indirect map[string]string + // Complete is false if resolution was best-effort (e.g. a package directory + // could not be read); the recipe then falls back to its LST-only path. + Complete bool +} + +// TidyResolver computes the exact tidy require set for an edited go.mod. The RPC +// server installs an implementation that delegates to the Java resolver (which +// performs all GOPROXY HTTP through the CLI HttpSender). ok is false when no +// resolver is installed (e.g. running outside the CLI). +type TidyResolver func(content string, mainImports []string, modulePath string, separateIndirect bool) (TidyRequireSet, bool) + +// tidyResolverKey is the ExecutionContext key under which the RPC server installs +// the TidyResolver. +const tidyResolverKey = "org.openrewrite.golang.modgraph.tidyResolver" -// SetModSource installs the ModSource used by recipe-time module-graph -// resolution. Called by the RPC server when it builds the recipe -// ExecutionContext. -func SetModSource(ctx *recipe.ExecutionContext, src modgraph.ModSource) { - ctx.PutMessage(modSourceKey, src) +// SetTidyResolver installs the recipe-time tidy resolver. Called by the RPC +// server when it builds the recipe ExecutionContext. +func SetTidyResolver(ctx *recipe.ExecutionContext, fn TidyResolver) { + ctx.PutMessage(tidyResolverKey, fn) } -// ModSourceFrom returns the installed ModSource, or nil when none is present -// (e.g. running outside the CLI, with no network access configured). -func ModSourceFrom(ctx *recipe.ExecutionContext) modgraph.ModSource { - if v, ok := ctx.GetMessage(modSourceKey); ok { - if s, ok := v.(modgraph.ModSource); ok { - return s +// TidyResolverFrom returns the installed TidyResolver, or nil when none is +// present (e.g. running outside the CLI, with no network access configured). +func TidyResolverFrom(ctx *recipe.ExecutionContext) TidyResolver { + if v, ok := ctx.GetMessage(tidyResolverKey); ok { + if fn, ok := v.(TidyResolver); ok { + return fn } } return nil @@ -52,14 +67,9 @@ func ModSourceFrom(ctx *recipe.ExecutionContext) modgraph.ModSource { // RefreshModel recomputes the GoResolutionResult for an (already-edited) go.mod // and returns the new marker. It re-parses the declared model from the current // go.mod text (module path, requires, replaces, excludes, retracts, go directive) -// and, when a ModSource is installed in ctx, re-resolves the module graph + build -// list — fetching dependency metadata via that source, which may reach the -// network through the CLI HttpSender. -// -// This is the mechanism by which a recipe that mutates dependencies refreshes the -// model so later recipes in the same run see an accurate view. Mirrors -// rewrite-maven's UpdateMavenModel. The declared `Requires` stay faithful to the -// file; the resolved view rides in BuildList/Graph. +// so later recipes in the same run see an accurate declared view. The resolved +// build list/graph is no longer carried in the marker — recipes that need the +// tidy require set obtain it from the Java resolver via the TidyResolver. // // Returns ok=false only if the go.mod cannot be parsed. func RefreshModel(gm *golang.GoMod, ctx *recipe.ExecutionContext) (golang.GoResolutionResult, bool) { @@ -73,11 +83,6 @@ func RefreshModel(gm *golang.GoMod, ctx *recipe.ExecutionContext) (golang.GoReso if prev := GetResolutionResult(gm); prev != nil { mrr.ResolvedDependencies = prev.ResolvedDependencies } - if src := ModSourceFrom(ctx); src != nil { - if res, e := modgraph.Resolve([]byte(content), src); e == nil { - modgraph.ApplyTo(res, mrr) - } - } return *mrr, true } diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java b/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java index 8023a840c97..48d224c7b45 100644 --- a/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java +++ b/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java @@ -15,6 +15,8 @@ */ package org.openrewrite.golang.rpc; +import io.moderne.jsonrpc.JsonRpc; +import io.moderne.jsonrpc.JsonRpcMethod; import lombok.Getter; import org.jspecify.annotations.Nullable; import org.openrewrite.ExecutionContext; @@ -22,6 +24,16 @@ import org.openrewrite.Parser; import org.openrewrite.SourceFile; import org.openrewrite.golang.GolangParser; +import org.openrewrite.golang.internal.modgraph.CacheSource; +import org.openrewrite.golang.internal.modgraph.ModSource; +import org.openrewrite.golang.internal.modgraph.ProxySource; +import org.openrewrite.golang.internal.modgraph.RequireSet; +import org.openrewrite.golang.internal.modgraph.ResolveResult; +import org.openrewrite.golang.internal.modgraph.Resolver; +import org.openrewrite.golang.internal.modgraph.TieredSource; +import org.openrewrite.golang.internal.modgraph.Tidy; +import org.openrewrite.ipc.http.HttpSender; +import org.openrewrite.ipc.http.HttpUrlConnectionSender; import org.openrewrite.marketplace.RecipeBundleResolver; import org.openrewrite.marketplace.RecipeMarketplace; import org.openrewrite.rpc.RewriteRpc; @@ -37,11 +49,13 @@ import java.io.IOException; import java.io.PrintStream; import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.time.Duration; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -73,6 +87,67 @@ public class GoRewriteRpc extends RewriteRpc { this.process = process; } + /** + * Registers {@code GoModResolveTidy}: the Go peer's go.mod recipe delegates + * the entire {@code go mod tidy} require-set computation to this method, which + * runs the pure-Java module resolver. All GOPROXY HTTP happens here, inside + * the host, through the configured {@link HttpSender} — so no peer-initiated + * fetch crosses the RPC boundary. The peer passes the GOPROXY/GOMODCACHE it + * resolved from the environment; resolution degrades gracefully to the cache + * when the proxy is disabled ({@code GOPROXY=off}) or unreachable. + */ + @Override + protected void registerLanguageMethods(JsonRpc jsonRpc) { + jsonRpc.rpc("GoModResolveTidy", new JsonRpcMethod() { + @Override + protected Object handle(GoModResolveTidyRequest request) { + return resolveTidy(request, getHttpSender()); + } + }); + } + + /** + * Runs the pure-Java module resolver for a {@code GoModResolveTidy} request + * and returns the {@code {direct, indirect, complete}} response map. Extracted + * from the RPC handler so it can be exercised directly. All GOPROXY HTTP is + * performed through {@code sender} (falling back to a direct sender only when + * none is configured); {@code GOPROXY=off} resolves cache-only. + */ + static Map resolveTidy(GoModResolveTidyRequest request, @Nullable HttpSender sender) { + HttpSender http = sender != null ? sender : new HttpUrlConnectionSender(); + String gomodcache = request.gomodcache == null || request.gomodcache.isEmpty() ? null : request.gomodcache; + + List tiers = new ArrayList<>(); + if (gomodcache != null) { + tiers.add(new CacheSource(gomodcache)); + } + if (request.goproxy != null && !"off".equals(request.goproxy.trim())) { + tiers.add(new ProxySource(request.goproxy, http, gomodcache)); + } + ModSource src = new TieredSource(tiers.toArray(new ModSource[0])); + + List mainImports = request.mainImports == null ? Collections.emptyList() : request.mainImports; + ResolveResult res = Resolver.resolve(request.goMod.getBytes(StandardCharsets.UTF_8), src); + RequireSet rs = Tidy.tidyRequireSet(mainImports, request.modulePath, request.goMod, res, src, + request.separateIndirect); + + Map out = new HashMap<>(); + out.put("direct", rs.direct()); + out.put("indirect", rs.indirect()); + out.put("complete", rs.complete()); + return out; + } + + /** Request body for the {@code GoModResolveTidy} RPC method. */ + static class GoModResolveTidyRequest { + public String goMod; + public @Nullable List mainImports; + public String modulePath; + public boolean separateIndirect; + public @Nullable String goproxy; + public @Nullable String gomodcache; + } + public static @Nullable GoRewriteRpc get() { return MANAGER.get(); } diff --git a/rewrite-go/src/test/java/org/openrewrite/golang/rpc/GoModResolveTidyTest.java b/rewrite-go/src/test/java/org/openrewrite/golang/rpc/GoModResolveTidyTest.java new file mode 100644 index 00000000000..32fca4f9f41 --- /dev/null +++ b/rewrite-go/src/test/java/org/openrewrite/golang/rpc/GoModResolveTidyTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.golang.rpc; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openrewrite.golang.internal.modgraph.GoModFile; +import org.openrewrite.golang.internal.modgraph.GoImports; +import org.openrewrite.golang.rpc.GoRewriteRpc.GoModResolveTidyRequest; +import org.openrewrite.ipc.http.HttpUrlConnectionSender; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Exercises the exact code path the CLI invokes for the Go go.mod recipe: the + * {@code GoModResolveTidy} RPC handler ({@link GoRewriteRpc#resolveTidy}), which + * builds the cache+proxy source from the request and runs the pure-Java + * resolver. Validates the direct/indirect result against {@code go mod tidy}, + * fetching dependency metadata over the proxy via {@link HttpUrlConnectionSender} + * — the same HttpSender path the host would use. + */ +class GoModResolveTidyTest { + + @Test + @SuppressWarnings("unchecked") + void handlerResolvesPruningCompletenessLikeGoModTidy(@TempDir Path dir) throws Exception { + assumeTrue(hasGo(), "needs the go toolchain + network"); + // gin: the case that genuinely exercises go1.17+ pruning-completeness + // promotion (kr/text, go.uber.org/mock) through the resolver. + write(dir, "go.mod", "module example.com/ginapp\n\ngo 1.25.0\n\nrequire github.com/gin-gonic/gin v1.10.0\n"); + write(dir, "main.go", "package main\n\nimport _ \"github.com/gin-gonic/gin\"\n\nfunc main() {}\n"); + runGo(dir, "mod", "tidy"); + + String tidied = new String(Files.readAllBytes(dir.resolve("go.mod")), StandardCharsets.UTF_8); + GoModFile golden = GoModFile.parse(tidied); + Set goldenDirect = new TreeSet<>(); + Set goldenIndirect = new TreeSet<>(); + for (GoModFile.Require r : golden.requires()) { + (r.indirect ? goldenIndirect : goldenDirect).add(r.path); + } + assumeTrue(!goldenIndirect.isEmpty(), "go mod tidy produced nothing (offline?)"); + + GoModResolveTidyRequest req = new GoModResolveTidyRequest(); + req.goMod = tidied; + req.mainImports = scanMainImports(dir); + req.modulePath = "example.com/ginapp"; + req.separateIndirect = true; + req.goproxy = runGo(dir, "env", "GOPROXY").trim(); + req.gomodcache = null; + + Map out = GoRewriteRpc.resolveTidy(req, new HttpUrlConnectionSender()); + + assertThat((Boolean) out.get("complete")).as("complete").isTrue(); + Map direct = (Map) out.get("direct"); + Map indirect = (Map) out.get("indirect"); + assertThat(new TreeSet<>(direct.keySet())).as("direct").isEqualTo(goldenDirect); + assertThat(new TreeSet<>(indirect.keySet())).as("indirect").isEqualTo(goldenIndirect); + } + + private static List scanMainImports(Path dir) throws Exception { + Set imports = new LinkedHashSet<>(); + try (Stream files = Files.list(dir)) { + for (Path f : (Iterable) files.filter(p -> p.getFileName().toString().endsWith(".go"))::iterator) { + imports.addAll(GoImports.parse(new String(Files.readAllBytes(f), StandardCharsets.UTF_8))); + } + } + return new ArrayList<>(imports); + } + + private static boolean hasGo() { + try { + return new ProcessBuilder("go", "version").start().waitFor() == 0; + } catch (Exception e) { + return false; + } + } + + private static void write(Path dir, String name, String content) throws Exception { + Files.write(dir.resolve(name), content.getBytes(StandardCharsets.UTF_8)); + } + + private static String runGo(Path dir, String... args) throws Exception { + String[] cmd = new String[args.length + 1]; + cmd[0] = "go"; + System.arraycopy(args, 0, cmd, 1, args.length); + ProcessBuilder pb = new ProcessBuilder(cmd).directory(dir.toFile()).redirectErrorStream(true); + pb.environment().put("GOFLAGS", "-mod=mod"); + Process p = pb.start(); + String out; + try (InputStream in = p.getInputStream()) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + bos.write(buf, 0, n); + } + out = new String(bos.toByteArray(), StandardCharsets.UTF_8); + } + p.waitFor(); + return out; + } +} diff --git a/rewrite-go/test/go_mod_resolution_test.go b/rewrite-go/test/go_mod_resolution_test.go deleted file mode 100644 index 89c328adcbc..00000000000 --- a/rewrite-go/test/go_mod_resolution_test.go +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package test - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" - "github.com/openrewrite/rewrite/rewrite-go/pkg/parser/modgraph" - "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" - golangrecipe "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe/golang" - "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" -) - -// TestRefreshModelRecipeTime exercises the Maven-style model refresh at recipe -// time: with a ModSource installed in the ExecutionContext (as the RPC server -// does), RefreshModel re-parses the declared go.mod and re-resolves the build -// list, and the UpdateGoModModel visitor swaps the GoResolutionResult marker. -// This is how a dependency-mutating recipe keeps the model current for the next -// recipe in the run. -func TestRefreshModelRecipeTime(t *testing.T) { - if testing.Short() { - t.Skip("needs the go toolchain + module cache") - } - dir := t.TempDir() - mustWrite(t, dir, "go.mod", "module example.com/rt\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/grafana/pyroscope-go v1.2.8\n\tgolang.org/x/mod v0.35.0\n)\n") - mustWrite(t, dir, "main.go", "package main\n\nimport (\n\t_ \"github.com/grafana/pyroscope-go\"\n\t_ \"golang.org/x/mod/modfile\"\n)\n\nfunc main() {}\n") - mustGo(t, dir, "mod", "tidy") - gomodcache := strings.TrimSpace(mustGo(t, dir, "env", "GOMODCACHE")) - content, err := os.ReadFile(filepath.Join(dir, "go.mod")) - if err != nil { - t.Fatal(err) - } - gm, err := parser.ParseGoModFile("go.mod", string(content)) - if err != nil { - t.Fatal(err) - } - - ctx := recipe.NewExecutionContext() - golangrecipe.SetModSource(ctx, modgraph.CacheSource(gomodcache)) - - // RefreshModel: declared requires + resolved build list. - marker, ok := golangrecipe.RefreshModel(gm, ctx) - if !ok { - t.Fatal("RefreshModel returned ok=false") - } - if len(marker.BuildList) == 0 { - t.Errorf("expected a non-empty resolved build list") - } - direct, indirect := requireSets(marker) - for _, p := range []string{"github.com/grafana/pyroscope-go", "golang.org/x/mod"} { - if !direct[p] { - t.Errorf("expected %s declared as a direct require", p) - } - } - for _, p := range []string{"github.com/grafana/pyroscope-go/godeltaprof", "github.com/klauspost/compress"} { - if !indirect[p] { - t.Errorf("expected %s declared as an indirect require", p) - } - } - - // UpdateGoModModel (the doAfterVisit-scheduled refresh) swaps the marker. - res := golangrecipe.UpdateGoModModel().Visit(gm, ctx) - gm2, ok := res.(*golang.GoMod) - if !ok { - t.Fatalf("UpdateGoModModel returned %T", res) - } - rr := golangrecipe.GetResolutionResult(gm2) - if rr == nil || len(rr.BuildList) == 0 { - t.Errorf("expected UpdateGoModModel to attach a resolved marker") - } - - // Without a ModSource the declared model still refreshes, but the resolved - // build list is empty (no network/cache access configured). - m2, ok := golangrecipe.RefreshModel(gm, recipe.NewExecutionContext()) - if !ok { - t.Errorf("expected declared refresh to succeed without a source") - } - if len(m2.BuildList) != 0 { - t.Errorf("expected no build list without a ModSource, got %d", len(m2.BuildList)) - } -} - -func requireSets(m golang.GoResolutionResult) (direct, indirect map[string]bool) { - direct, indirect = map[string]bool{}, map[string]bool{} - for _, r := range m.Requires { - if r.Indirect { - indirect[r.ModulePath] = true - } else { - direct[r.ModulePath] = true - } - } - return direct, indirect -} - -func mustWrite(t *testing.T, dir, name, content string) { - t.Helper() - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -func mustGo(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("go", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("go %s: %v\n%s", strings.Join(args, " "), err, out) - } - return string(out) -} From e1308e1b7eb8fd7f12f6682bfb4e223cd8bb5ada Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Tue, 23 Jun 2026 18:02:19 -0700 Subject: [PATCH 16/19] Go: wire test for the GoModResolveTidy RPC client half Drive resolveTidyViaJava against a canned host response: assert it parses {direct, indirect, complete} correctly and that the request it writes carries the exact method name and param field names (goMod, mainImports, modulePath, separateIndirect, goproxy, gomodcache) the Java GoModResolveTidyRequest expects. Together with GoModResolveTidyTest (the Java handler half, validated against `go mod tidy`), this pins both ends of the cross-process contract that neither single-sided test can see on its own. --- rewrite-go/cmd/rpc/resolve_tidy_test.go | 100 ++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 rewrite-go/cmd/rpc/resolve_tidy_test.go diff --git a/rewrite-go/cmd/rpc/resolve_tidy_test.go b/rewrite-go/cmd/rpc/resolve_tidy_test.go new file mode 100644 index 00000000000..4c784617065 --- /dev/null +++ b/rewrite-go/cmd/rpc/resolve_tidy_test.go @@ -0,0 +1,100 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" +) + +// TestResolveTidyViaJavaWireProtocol exercises the Go half of the +// GoModResolveTidy RPC: it feeds a canned Java response and asserts both that +// resolveTidyViaJava parses it correctly AND that the request it writes carries +// the exact field names the Java GoModResolveTidyRequest expects. This guards +// the cross-process contract that the in-Java handler test cannot see. +func TestResolveTidyViaJavaWireProtocol(t *testing.T) { + s := newServer(serverConfig{logFile: filepath.Join(t.TempDir(), "server.log")}) + + // Canned host response: direct/indirect/complete, JSON-RPC framed. + respBody := `{"jsonrpc":"2.0","id":"go-GoModResolveTidy","result":` + + `{"direct":{"github.com/a/x":"v1.2.3"},"indirect":{"github.com/b/y":"v0.4.0"},"complete":true}}` + framed := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(respBody), respBody) + s.reader = bufio.NewReader(strings.NewReader(framed)) + var written bytes.Buffer + s.writer = &written + + rs, ok := s.resolveTidyViaJava("module example.com/m\n\ngo 1.21\n", + []string{"github.com/a/x", "fmt"}, "example.com/m", true) + + // Response parsed correctly. + if !ok { + t.Fatal("expected ok=true") + } + if !rs.Complete { + t.Error("expected Complete=true") + } + if rs.Direct["github.com/a/x"] != "v1.2.3" { + t.Errorf("direct: got %v", rs.Direct) + } + if rs.Indirect["github.com/b/y"] != "v0.4.0" { + t.Errorf("indirect: got %v", rs.Indirect) + } + + // Request written with the method and param field names the Java side expects. + body := written.String() + if i := strings.Index(body, "\r\n\r\n"); i >= 0 { + body = body[i+4:] + } + var req struct { + Method string `json:"method"` + Params struct { + GoMod string `json:"goMod"` + MainImports []string `json:"mainImports"` + ModulePath string `json:"modulePath"` + SeparateIndirect bool `json:"separateIndirect"` + Goproxy string `json:"goproxy"` + Gomodcache string `json:"gomodcache"` + } `json:"params"` + } + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("request JSON: %v\nbody=%s", err, body) + } + if req.Method != "GoModResolveTidy" { + t.Errorf("method: got %q", req.Method) + } + if !strings.Contains(req.Params.GoMod, "module example.com/m") { + t.Errorf("goMod not propagated: %q", req.Params.GoMod) + } + if req.Params.ModulePath != "example.com/m" { + t.Errorf("modulePath: got %q", req.Params.ModulePath) + } + if !req.Params.SeparateIndirect { + t.Error("separateIndirect should be true") + } + if len(req.Params.MainImports) != 2 || req.Params.MainImports[0] != "github.com/a/x" { + t.Errorf("mainImports: got %v", req.Params.MainImports) + } + if req.Params.Goproxy == "" || req.Params.Gomodcache == "" { + t.Errorf("goproxy/gomodcache should be populated from env: %q / %q", + req.Params.Goproxy, req.Params.Gomodcache) + } +} From df403d298cb3baa1441274d0fda60e14c858b0be Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Tue, 23 Jun 2026 23:14:46 -0700 Subject: [PATCH 17/19] Go: move HttpSender plumbing out of core RewriteRpc into GoRewriteRpc The httpSender field, setHttpSender/getHttpSender, and setHttpSenderFrom were added to core RewriteRpc to feed the Go module-graph resolver. With the generic Http RPC method gone and the resolver now the sole consumer, core no longer needs any HttpSender knowledge. Core RewriteRpc keeps only a generic, language-agnostic beforeSend(Object) hook invoked before each visit/batchVisit/generate is dispatched to the peer. GoRewriteRpc overrides it to capture the operation's ExecutionContext HttpSender into its own field, which the GoModResolveTidy handler uses for GOPROXY fetches; parseProject captures it the same way. setHttpSender had no callers outside this initiative (the CLI configures the sender on the ExecutionContext, not the RPC peer), so removing it is safe. Verified: rewrite-core rpc tests, the rewrite-go resolver + handler tests, and a full modw end-to-end run (gin, exact `go mod tidy` parity) all pass. --- .../java/org/openrewrite/rpc/RewriteRpc.java | 43 ++++++------------- .../openrewrite/golang/rpc/GoRewriteRpc.java | 26 +++++++++-- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java b/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java index 3eab13c8648..5d33ad673f0 100644 --- a/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java +++ b/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java @@ -36,8 +36,6 @@ import org.openrewrite.tree.ParsingEventListener; import org.openrewrite.tree.ParsingExecutionContextView; -import org.openrewrite.HttpSenderExecutionContextView; -import org.openrewrite.ipc.http.HttpSender; import java.io.PrintStream; import java.util.HashMap; @@ -67,7 +65,6 @@ @SuppressWarnings("UnusedReturnValue") public class RewriteRpc { private final JsonRpc jsonRpc; - private volatile @Nullable HttpSender httpSender; private final AtomicInteger batchSize = new AtomicInteger(1000); private Duration timeout = Duration.ofSeconds(30); private Supplier livenessCheck = () -> null; @@ -251,26 +248,23 @@ protected Boolean handle(Void noParams) { /** * Hook for a subclass to register additional, language-specific RPC methods * on the bidirectional channel before it is bound. The default registers - * nothing. Implementations may delegate network calls to {@link - * #getHttpSender()} so proxy/auth/TLS are honored. Invoked from the - * constructor, so implementations must not rely on subclass instance state. + * nothing. A method that needs the current operation's {@link + * org.openrewrite.ExecutionContext} (e.g. to honor a CLI-configured + * HttpSender) can capture it via {@link #beforeSend(Object)}. Invoked from + * the constructor, so implementations must not rely on subclass instance + * state. */ protected void registerLanguageMethods(JsonRpc jsonRpc) { } /** - * Configure the {@link HttpSender} a language method may use to perform HTTP - * on the peer's behalf. Typically set from a parse/recipe ExecutionContext so - * fetches use the CLI-configured sender (proxy, auth, TLS). + * Hook invoked just before an operation (visit, batch visit, generate) is + * dispatched to the peer, with that operation's parameter — typically the + * {@link org.openrewrite.ExecutionContext}. Subclasses may react, for + * example by capturing the context so a language RPC method serving the + * peer's callback can use it. The default does nothing. */ - public RewriteRpc setHttpSender(@Nullable HttpSender httpSender) { - this.httpSender = httpSender; - return this; - } - - /** The configured {@link HttpSender}, for use by language RPC methods. */ - protected @Nullable HttpSender getHttpSender() { - return httpSender; + protected void beforeSend(@Nullable Object p) { } public RewriteRpc livenessCheck(Supplier livenessCheck) { @@ -323,17 +317,8 @@ public void reset() { return visit(sourceFile, visitorName, p, null); } - // Make the HttpSender from the current operation's ExecutionContext available - // to the peer's Http RPC method (e.g. the Go module-graph resolver fetching - // from a GOPROXY at recipe time). - private void setHttpSenderFrom(@Nullable Object p) { - if (p instanceof ExecutionContext) { - this.httpSender = HttpSenderExecutionContextView.view((ExecutionContext) p).getHttpSender(); - } - } - public

@Nullable Tree visit(Tree tree, String visitorName, P p, @Nullable Cursor cursor) { - setHttpSenderFrom(p); + beforeSend(p); // Set the local state of this tree, so that when the remote asks for it, we know what to send. localObjects.put(tree.getId().toString(), tree); @@ -354,7 +339,7 @@ private void setHttpSenderFrom(@Nullable Object p) { public

BatchVisitResponse batchVisit(Tree tree, P p, @Nullable Cursor cursor, List visitors) { - setHttpSenderFrom(p); + beforeSend(p); String treeId = tree.getId().toString(); localObjects.put(treeId, tree); @@ -372,7 +357,7 @@ public

BatchVisitResponse batchVisit(Tree tree, P p, @Nullable Cursor cursor } public Collection generate(String remoteRecipeId, ExecutionContext ctx) { - setHttpSenderFrom(ctx); + beforeSend(ctx); String ctxId = maybeUnwrapExecutionContext(ctx); GenerateResponse response = RewriteRpcExecutionContextView.view(ctx).withInFlightSlot(() -> send("Generate", new Generate(remoteRecipeId, ctxId), GenerateResponse.class)); diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java b/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java index 48d224c7b45..96526c7c7ff 100644 --- a/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java +++ b/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java @@ -17,6 +17,7 @@ import io.moderne.jsonrpc.JsonRpc; import io.moderne.jsonrpc.JsonRpcMethod; +import lombok.AccessLevel; import lombok.Getter; import org.jspecify.annotations.Nullable; import org.openrewrite.ExecutionContext; @@ -79,6 +80,15 @@ public class GoRewriteRpc extends RewriteRpc { private final Map commandEnv; private final RewriteRpcProcess process; + /** + * The HttpSender of the operation currently being dispatched to the Go peer, + * captured from its ExecutionContext (see {@link #beforeSend}). It lets the + * {@code GoModResolveTidy} handler perform GOPROXY fetches through the + * CLI-configured sender when the Go recipe calls back during that operation. + */ + @Getter(AccessLevel.NONE) + private volatile @Nullable HttpSender httpSender; + GoRewriteRpc(RewriteRpcProcess process, RecipeMarketplace marketplace, List resolvers, String command, Map commandEnv) { super(process.getRpcClient(), marketplace, resolvers); @@ -87,6 +97,13 @@ public class GoRewriteRpc extends RewriteRpc { this.process = process; } + @Override + protected void beforeSend(@Nullable Object p) { + if (p instanceof ExecutionContext) { + this.httpSender = HttpSenderExecutionContextView.view((ExecutionContext) p).getHttpSender(); + } + } + /** * Registers {@code GoModResolveTidy}: the Go peer's go.mod recipe delegates * the entire {@code go mod tidy} require-set computation to this method, which @@ -101,7 +118,7 @@ protected void registerLanguageMethods(JsonRpc jsonRpc) { jsonRpc.rpc("GoModResolveTidy", new JsonRpcMethod() { @Override protected Object handle(GoModResolveTidyRequest request) { - return resolveTidy(request, getHttpSender()); + return resolveTidy(request, httpSender); } }); } @@ -311,9 +328,10 @@ public Stream parseProject(Path projectPath, @Nullable List * @return Stream of parsed source files */ public Stream parseProject(Path projectPath, @Nullable List exclusions, @Nullable Path relativeTo, ExecutionContext ctx) { - // Route the Go module-graph resolver's GOPROXY fetches through the - // CLI-configured HttpSender (handled by the "Http" RPC method). - setHttpSender(HttpSenderExecutionContextView.view(ctx).getHttpSender()); + // Capture this parse's HttpSender so the Go module-graph resolver's + // GOPROXY fetches (if the parser calls back) go through the CLI-configured + // sender. Recipe-time operations capture it via beforeSend instead. + this.httpSender = HttpSenderExecutionContextView.view(ctx).getHttpSender(); ParsingEventListener parsingListener = ParsingExecutionContextView.view(ctx).getParsingListener(); return StreamSupport.stream(new Spliterator() { From 9bf553584a7f2725920877f1011178f6d4cfa376 Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Tue, 23 Jun 2026 23:29:29 -0700 Subject: [PATCH 18/19] =?UTF-8?q?Go:=20keep=20RewriteRpc=20untouched=20?= =?UTF-8?q?=E2=80=94=20self-register=20+=20capture=20HttpSender=20in=20GoR?= =?UTF-8?q?ewriteRpc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the registerLanguageMethods/beforeSend hooks from core RewriteRpc, which is now byte-for-byte origin/main again. Everything the Go resolver needs lives in GoRewriteRpc: - It registers GoModResolveTidy itself, on the JsonRpc obtained from process.getRpcClient(), in its constructor. The channel's method table is a ConcurrentHashMap consulted at dispatch time and bind() only starts a read loop, so registering after super()'s bind() is safe; the method is only ever invoked during a recipe run, long after construction. - It captures the operation's HttpSender by overriding the public visit/batchVisit/generate and reading it from the ExecutionContext before delegating to super (parseProject already captured it for parse time). No new extension points in the shared core protocol. Verified: rewrite-core rpc tests, rewrite-go resolver/handler tests, and a full modw end-to-end run (gin, exact `go mod tidy` parity) all pass. --- .../java/org/openrewrite/rpc/RewriteRpc.java | 34 --------- .../openrewrite/golang/rpc/GoRewriteRpc.java | 76 +++++++++++++------ 2 files changed, 52 insertions(+), 58 deletions(-) diff --git a/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java b/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java index 5d33ad673f0..b8eb1b54aa5 100644 --- a/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java +++ b/rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java @@ -36,10 +36,7 @@ import org.openrewrite.tree.ParsingEventListener; import org.openrewrite.tree.ParsingExecutionContextView; - import java.io.PrintStream; -import java.util.HashMap; -import java.util.Map; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -236,37 +233,9 @@ protected Boolean handle(Void noParams) { } }); - // Language-specific RPC methods (registered by subclasses) are bound - // here, before jsonRpc.bind(). This is where, for example, the Go - // support registers its module-graph resolver — keeping domain-specific, - // network-performing methods out of the generic core protocol. - registerLanguageMethods(jsonRpc); - jsonRpc.bind(); } - /** - * Hook for a subclass to register additional, language-specific RPC methods - * on the bidirectional channel before it is bound. The default registers - * nothing. A method that needs the current operation's {@link - * org.openrewrite.ExecutionContext} (e.g. to honor a CLI-configured - * HttpSender) can capture it via {@link #beforeSend(Object)}. Invoked from - * the constructor, so implementations must not rely on subclass instance - * state. - */ - protected void registerLanguageMethods(JsonRpc jsonRpc) { - } - - /** - * Hook invoked just before an operation (visit, batch visit, generate) is - * dispatched to the peer, with that operation's parameter — typically the - * {@link org.openrewrite.ExecutionContext}. Subclasses may react, for - * example by capturing the context so a language RPC method serving the - * peer's callback can use it. The default does nothing. - */ - protected void beforeSend(@Nullable Object p) { - } - public RewriteRpc livenessCheck(Supplier livenessCheck) { this.livenessCheck = livenessCheck; return this; @@ -318,7 +287,6 @@ public void reset() { } public

@Nullable Tree visit(Tree tree, String visitorName, P p, @Nullable Cursor cursor) { - beforeSend(p); // Set the local state of this tree, so that when the remote asks for it, we know what to send. localObjects.put(tree.getId().toString(), tree); @@ -339,7 +307,6 @@ public void reset() { public

BatchVisitResponse batchVisit(Tree tree, P p, @Nullable Cursor cursor, List visitors) { - beforeSend(p); String treeId = tree.getId().toString(); localObjects.put(treeId, tree); @@ -357,7 +324,6 @@ public

BatchVisitResponse batchVisit(Tree tree, P p, @Nullable Cursor cursor } public Collection generate(String remoteRecipeId, ExecutionContext ctx) { - beforeSend(ctx); String ctxId = maybeUnwrapExecutionContext(ctx); GenerateResponse response = RewriteRpcExecutionContextView.view(ctx).withInFlightSlot(() -> send("Generate", new Generate(remoteRecipeId, ctxId), GenerateResponse.class)); diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java b/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java index 96526c7c7ff..308420118ad 100644 --- a/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java +++ b/rewrite-go/src/main/java/org/openrewrite/golang/rpc/GoRewriteRpc.java @@ -15,15 +15,16 @@ */ package org.openrewrite.golang.rpc; -import io.moderne.jsonrpc.JsonRpc; import io.moderne.jsonrpc.JsonRpcMethod; import lombok.AccessLevel; import lombok.Getter; import org.jspecify.annotations.Nullable; +import org.openrewrite.Cursor; import org.openrewrite.ExecutionContext; import org.openrewrite.HttpSenderExecutionContextView; import org.openrewrite.Parser; import org.openrewrite.SourceFile; +import org.openrewrite.Tree; import org.openrewrite.golang.GolangParser; import org.openrewrite.golang.internal.modgraph.CacheSource; import org.openrewrite.golang.internal.modgraph.ModSource; @@ -40,6 +41,8 @@ import org.openrewrite.rpc.RewriteRpc; import org.openrewrite.rpc.RewriteRpcProcess; import org.openrewrite.rpc.RewriteRpcProcessManager; +import org.openrewrite.rpc.request.BatchVisit; +import org.openrewrite.rpc.request.BatchVisitResponse; import org.openrewrite.rpc.request.Parse; import org.openrewrite.rpc.request.ParseResponse; import org.openrewrite.tree.ParseError; @@ -57,6 +60,7 @@ import java.nio.file.StandardOpenOption; import java.time.Duration; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -82,9 +86,10 @@ public class GoRewriteRpc extends RewriteRpc { /** * The HttpSender of the operation currently being dispatched to the Go peer, - * captured from its ExecutionContext (see {@link #beforeSend}). It lets the - * {@code GoModResolveTidy} handler perform GOPROXY fetches through the - * CLI-configured sender when the Go recipe calls back during that operation. + * captured from its ExecutionContext by the visit/batchVisit/generate + * overrides (and {@link #parseProject}). It lets the {@code GoModResolveTidy} + * handler perform GOPROXY fetches through the CLI-configured sender when the + * Go recipe calls back during that operation. */ @Getter(AccessLevel.NONE) private volatile @Nullable HttpSender httpSender; @@ -95,27 +100,20 @@ public class GoRewriteRpc extends RewriteRpc { this.command = command; this.commandEnv = commandEnv; this.process = process; - } - - @Override - protected void beforeSend(@Nullable Object p) { - if (p instanceof ExecutionContext) { - this.httpSender = HttpSenderExecutionContextView.view((ExecutionContext) p).getHttpSender(); - } - } - /** - * Registers {@code GoModResolveTidy}: the Go peer's go.mod recipe delegates - * the entire {@code go mod tidy} require-set computation to this method, which - * runs the pure-Java module resolver. All GOPROXY HTTP happens here, inside - * the host, through the configured {@link HttpSender} — so no peer-initiated - * fetch crosses the RPC boundary. The peer passes the GOPROXY/GOMODCACHE it - * resolved from the environment; resolution degrades gracefully to the cache - * when the proxy is disabled ({@code GOPROXY=off}) or unreachable. - */ - @Override - protected void registerLanguageMethods(JsonRpc jsonRpc) { - jsonRpc.rpc("GoModResolveTidy", new JsonRpcMethod() { + // Register the Go module-graph resolver on the same bidirectional channel + // — the Go peer's go.mod recipe delegates the entire `go mod tidy` + // require-set computation here, to the pure-Java resolver. All GOPROXY + // HTTP happens inside the host through the captured HttpSender, so no + // peer-initiated fetch crosses the RPC boundary; the peer passes the + // GOPROXY/GOMODCACHE it resolved from the environment, and resolution + // degrades gracefully to the cache when the proxy is off or unreachable. + // + // The channel's method table is a ConcurrentHashMap consulted at dispatch + // time, so registering after super()'s jsonRpc.bind() is safe; + // GoModResolveTidy is only ever invoked during a recipe run, long after + // construction. + process.getRpcClient().rpc("GoModResolveTidy", new JsonRpcMethod() { @Override protected Object handle(GoModResolveTidyRequest request) { return resolveTidy(request, httpSender); @@ -123,6 +121,36 @@ protected Object handle(GoModResolveTidyRequest request) { }); } + // The visit/batchVisit/generate overrides capture the operation's HttpSender + // (from its ExecutionContext) so the GoModResolveTidy handler can route + // GOPROXY fetches through the CLI-configured sender when the Go recipe calls + // back during that operation. + + @Override + public

@Nullable Tree visit(Tree tree, String visitorName, P p, @Nullable Cursor cursor) { + captureHttpSender(p); + return super.visit(tree, visitorName, p, cursor); + } + + @Override + public

BatchVisitResponse batchVisit(Tree tree, P p, @Nullable Cursor cursor, + List visitors) { + captureHttpSender(p); + return super.batchVisit(tree, p, cursor, visitors); + } + + @Override + public Collection generate(String remoteRecipeId, ExecutionContext ctx) { + captureHttpSender(ctx); + return super.generate(remoteRecipeId, ctx); + } + + private void captureHttpSender(@Nullable Object p) { + if (p instanceof ExecutionContext) { + this.httpSender = HttpSenderExecutionContextView.view((ExecutionContext) p).getHttpSender(); + } + } + /** * Runs the pure-Java module resolver for a {@code GoModResolveTidy} request * and returns the {@code {direct, indirect, complete}} response map. Extracted From c0c3d0195e8a60317beff6985bb66d1dca2ea9fb Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Wed, 24 Jun 2026 15:36:44 -0700 Subject: [PATCH 19/19] Moving go-mod-tidy to recipes-go --- rewrite-go/pkg/recipe/golang/activate.go | 1 - rewrite-go/pkg/recipe/golang/go_mod_tidy.go | 557 ------------------ .../recipe/golang/go_mod_tidy_scope_test.go | 58 -- rewrite-go/test/go_mod_tidy_recipe_test.go | 96 --- 4 files changed, 712 deletions(-) delete mode 100644 rewrite-go/pkg/recipe/golang/go_mod_tidy.go delete mode 100644 rewrite-go/pkg/recipe/golang/go_mod_tidy_scope_test.go delete mode 100644 rewrite-go/test/go_mod_tidy_recipe_test.go diff --git a/rewrite-go/pkg/recipe/golang/activate.go b/rewrite-go/pkg/recipe/golang/activate.go index 176a3d37603..b12ecb220fd 100644 --- a/rewrite-go/pkg/recipe/golang/activate.go +++ b/rewrite-go/pkg/recipe/golang/activate.go @@ -33,5 +33,4 @@ func Activate(r *recipe.Registry) { r.Register(&RemoveUnusedImports{}, golangCategory) r.Register(&OrderImports{}, golangCategory) r.Register(&RenamePackage{}, golangCategory) - r.Register(&GoModTidy{}, golangCategory) } diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy.go deleted file mode 100644 index 83598f834af..00000000000 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy.go +++ /dev/null @@ -1,557 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package golang - -import ( - "sort" - "strconv" - "strings" - - "github.com/google/uuid" - - "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" - "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" - "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe/golang/internal" - "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" - "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/java" - "github.com/openrewrite/rewrite/rewrite-go/pkg/visitor" -) - -// GoModTidy emulates `go mod tidy`'s effect on go.mod. It computes the exact -// require set the toolchain would write — adding missing requires, removing -// unused ones, classifying each as direct or `// indirect`, and including the -// go>=1.17 pruning-completeness roots — then rewrites the `require` blocks -// (sorted by module path/version, the toolchain's key). -// -// The require set is computed at recipe time from the resolved module graph, -// the imports scanned from the project's .go files, and a dependency ModSource -// (the local module cache plus a write-through GOPROXY reached over the CLI -// HttpSender). When no source is available or resolution cannot complete (e.g. -// offline), it degrades to an LST-only pass that re-marks and sorts the -// existing requires without dropping anything. go.sum is NOT recomputed. -// -// GoModTidy is a ScanningRecipe: the scan phase records, per module, the -// imports of every .go file it owns; the edit phase rewrites each go.mod -// against only its own module's files (so multi-module repositories tidy -// each go.mod independently). -type GoModTidy struct { - recipe.ScanningBase -} - -func (r *GoModTidy) Name() string { return "org.openrewrite.golang.GoModTidy" } -func (r *GoModTidy) DisplayName() string { return "Tidy go.mod" } -func (r *GoModTidy) Description() string { - return "Emulate `go mod tidy`'s effect on go.mod: add missing requires, remove unused ones, " + - "classify each as direct or `// indirect` (including go>=1.17 pruning-completeness roots), " + - "and sort the `require` blocks. Does not recompute go.sum." -} - -// tidyAcc is the accumulator threaded across the scan phase. Data is kept -// PER-MODULE (keyed by the directory of each go.mod) so that a repository with -// several modules — e.g. a root module plus a nested `internal/tools` module — -// tidies each go.mod against only its own files. Without this, a nested -// module's imports (such as the classic `//go:build tools` tools.go) would leak -// into the root module's require set and get misclassified as direct. -type tidyAcc struct { - // goModDirs is the set of directories that contain a go.mod (module roots). - goModDirs map[string]bool - // modulePathByDir maps a module's directory to its declared module path. - modulePathByDir map[string]string - // requireModsByDir maps a module's directory to its declared require set. - requireModsByDir map[string]map[string]bool - // fileImports maps each .go file's source path to the imports it declares. - fileImports map[string][]string -} - -func (r *GoModTidy) InitialValue(ctx *recipe.ExecutionContext) any { - return &tidyAcc{ - goModDirs: map[string]bool{}, - modulePathByDir: map[string]string{}, - requireModsByDir: map[string]map[string]bool{}, - fileImports: map[string][]string{}, - } -} - -// sourceDir returns the directory portion of a repo-relative source path -// ("internal/tools/go.mod" -> "internal/tools"; "main.go" -> ""). -func sourceDir(p string) string { - if i := strings.LastIndex(p, "/"); i >= 0 { - return p[:i] - } - return "" -} - -// ownerDir returns the directory of the most-specific module that contains the -// file at fileDir — the longest go.mod directory that is fileDir or an ancestor -// of it. The root module (dir "") owns anything no nested module claims. -func ownerDir(fileDir string, dirs map[string]bool) string { - best, bestLen := "", -1 - for d := range dirs { - if d == "" || fileDir == d || strings.HasPrefix(fileDir, d+"/") { - if len(d) > bestLen { - best, bestLen = d, len(d) - } - } - } - return best -} - -func (r *GoModTidy) Scanner(acc any) recipe.TreeVisitor { - return visitor.Init(&goModTidyScanner{acc: acc.(*tidyAcc)}) -} - -func (r *GoModTidy) EditorWithData(acc any) recipe.TreeVisitor { - return visitor.Init(&goModTidyEditor{acc: acc.(*tidyAcc)}) -} - -// --- scan phase --- -// -// The scanner records, per source file, the imports of each Go.CompilationUnit, -// plus each module's declared path and require set. Note that `go mod tidy` -// unions imports across ALL build configurations (every GOOS/GOARCH and tag), -// whereas the parser type-checks under one host build context — so files the -// host context excludes (e.g. a `//go:build windows` file on Linux) are not -// parsed into a CompilationUnit and their imports are not scanned here. That is -// an accepted limitation: it only matters for a module imported SOLELY by a -// platform-gated file, which is rare. (Recovering those would mean parsing the -// excluded files imports-only — see ParsePackage, which currently omits them.) - -type goModTidyScanner struct { - visitor.GoVisitor - acc *tidyAcc -} - -func (v *goModTidyScanner) VisitCompilationUnit(cu *golang.CompilationUnit, p any) java.J { - if cu.Imports != nil { - imps := make([]string, 0, len(cu.Imports.Elements)) - for _, rp := range cu.Imports.Elements { - if path := internal.ImportPath(rp.Element); path != "" { - imps = append(imps, path) - } - } - v.acc.fileImports[cu.SourcePath] = imps - } - return v.GoVisitor.VisitCompilationUnit(cu, p) -} - -// VisitGoMod records each module's declared path and require set, keyed by the -// go.mod's directory, so the editor can tidy each module against only its own -// files. -func (v *goModTidyScanner) VisitGoMod(gm *golang.GoMod, p any) java.Tree { - dir := sourceDir(gm.SourcePath) - v.acc.goModDirs[dir] = true - modulePath, requires := parseGoModDeclared(gm) - if modulePath != "" { - v.acc.modulePathByDir[dir] = modulePath - } - v.acc.requireModsByDir[dir] = requires - return v.GoVisitor.VisitGoMod(gm, p) -} - -// parseGoModDeclared extracts a go.mod's module path and the set of module paths -// it declares in `require` (both single-line directives and block entries). -func parseGoModDeclared(gm *golang.GoMod) (modulePath string, requires map[string]bool) { - requires = map[string]bool{} - for _, st := range gm.Statements { - switch s := st.Element.(type) { - case *golang.GoModDirective: - switch s.Keyword { - case "module": - if len(s.Values) > 0 { - modulePath = s.Values[0].Text - } - case "require": - if len(s.Values) > 0 { - requires[s.Values[0].Text] = true - } - } - case *golang.GoModBlock: - if s.Keyword == "require" { - for _, e := range s.Entries { - if path, _ := moduleOf(e.Element); path != "" { - requires[path] = true - } - } - } - } - } - return modulePath, requires -} - -// --- edit phase --- - -type goModTidyEditor struct { - visitor.GoVisitor - acc *tidyAcc - // Per-module scope for the go.mod currently being edited, set at the top of - // VisitGoMod so this module is tidied against only its own files. - curModulePath string - curRequireMods map[string]bool - curImports []string -} - -// reqEntry is a single collected require entry during the rebuild. -type reqEntry struct { - path string - version string - indirect bool -} - -// ownedImports returns the deduped imports of every scanned .go file whose -// nearest-ancestor go.mod is the module at dir — i.e. the files that belong to -// this module and not to a nested one. -func (v *goModTidyEditor) ownedImports(dir string) []string { - seen := map[string]bool{} - var out []string - for path, imps := range v.acc.fileImports { - if ownerDir(sourceDir(path), v.acc.goModDirs) != dir { - continue - } - for _, imp := range imps { - if !seen[imp] { - seen[imp] = true - out = append(out, imp) - } - } - } - return out -} - -func (v *goModTidyEditor) VisitGoMod(gm *golang.GoMod, p any) java.Tree { - gm = v.GoVisitor.VisitGoMod(gm, p).(*golang.GoMod) - - // Scope all import/require data to THIS module's directory. - dir := sourceDir(gm.SourcePath) - v.curModulePath = v.acc.modulePathByDir[dir] - v.curRequireMods = v.acc.requireModsByDir[dir] - if v.curRequireMods == nil { - v.curRequireMods = map[string]bool{} - } - v.curImports = v.ownedImports(dir) - - separateIndirect := goVersionAtLeast(gm, 1, 17) - - // Strip the existing require statements, remembering where the first one - // was and its leading whitespace. - firstIdx := -1 - var firstPrefix java.Space - var collected []reqEntry - kept := make([]java.RightPadded[golang.GoModStatement], 0, len(gm.Statements)) - for _, st := range gm.Statements { - switch s := st.Element.(type) { - case *golang.GoModBlock: - if s.Keyword == "require" { - if firstIdx == -1 { - firstIdx = len(kept) - firstPrefix = s.Prefix - } - for _, e := range s.Entries { - if path, version := moduleOf(e.Element); path != "" { - collected = append(collected, reqEntry{path, version, false}) - } - } - continue - } - case *golang.GoModDirective: - if s.Keyword == "require" && len(s.Values) > 0 { - if firstIdx == -1 { - firstIdx = len(kept) - firstPrefix = s.Prefix - } - version := "" - if len(s.Values) > 1 { - version = s.Values[1].Text - } - collected = append(collected, reqEntry{s.Values[0].Text, version, false}) - continue - } - } - kept = append(kept, st) - } - - // Determine the entries to emit, in priority order: - // 1. Compute the exact tidy require set NOW via the Java resolver (the - // scanned imports + the current go.mod, with dependency metadata fetched - // on the host through the CLI HttpSender). This is the production path. - // 2. LST-only: re-mark the declared requires by direct-import and sort them, - // preserving the existing set (the offline / no-resolver degradation). - var entries []reqEntry - if computed, ok := v.computeTidySet(gm, separateIndirect, p); ok { - entries = computed - } else { - direct := v.directModules() - for _, e := range collected { - entries = append(entries, reqEntry{e.path, e.version, !direct[e.path]}) - } - } - - if len(entries) == 0 { - if firstIdx == -1 { - return gm // nothing to do - } - return gm.WithStatements(kept) // requires all pruned away - } - if firstIdx == -1 { - // No existing require statement to anchor on (e.g. all were missing - // and the graph added them); insert after the header directives. - firstIdx = headerInsertIndex(kept) - firstPrefix = java.Space{Whitespace: "\n"} - } - - // Partition into the require statements the toolchain would emit: for - // go >= 1.17, a direct-only statement followed by an indirect-only one; - // otherwise a single mixed statement. Each group is sorted by path. - var groups [][]reqEntry - if separateIndirect { - var directGroup, indirectGroup []reqEntry - for _, e := range entries { - if e.indirect { - indirectGroup = append(indirectGroup, e) - } else { - directGroup = append(directGroup, e) - } - } - if len(directGroup) > 0 { - groups = append(groups, directGroup) - } - if len(indirectGroup) > 0 { - groups = append(groups, indirectGroup) - } - } else { - groups = append(groups, entries) - } - - reqStmts := make([]java.RightPadded[golang.GoModStatement], 0, len(groups)) - for i, g := range groups { - sortEntries(g) - prefix := java.Space{Whitespace: "\n"} - if i == 0 { - prefix = firstPrefix - } - reqStmts = append(reqStmts, buildRequireStatement(g, prefix)) - } - - final := make([]java.RightPadded[golang.GoModStatement], 0, len(kept)+len(reqStmts)) - final = append(final, kept[:firstIdx]...) - final = append(final, reqStmts...) - final = append(final, kept[firstIdx:]...) - return gm.WithStatements(final) -} - -// sortEntries orders require entries by module path, then version. -func sortEntries(es []reqEntry) { - sort.SliceStable(es, func(i, j int) bool { - if es[i].path != es[j].path { - return es[i].path < es[j].path - } - return es[i].version < es[j].version - }) -} - -// buildRequireStatement renders a sorted group of entries as a single -// require statement: a single-line directive when the group has one entry, -// or a factored `require ( … )` block otherwise. -func buildRequireStatement(group []reqEntry, prefix java.Space) java.RightPadded[golang.GoModStatement] { - if len(group) == 1 { - e := group[0] - d := &golang.GoModDirective{ - Ident: uuid.New(), - Prefix: prefix, - Keyword: "require", - Values: []*golang.GoModValue{ - {Ident: uuid.New(), Prefix: java.SingleSpace, Text: e.path}, - {Ident: uuid.New(), Prefix: java.SingleSpace, Text: e.version}, - }, - } - return java.RightPadded[golang.GoModStatement]{ - Element: d, - After: setIndirectComment(java.Space{}, e.indirect), - } - } - - blockEntries := make([]java.RightPadded[golang.GoModStatement], len(group)) - for i, e := range group { - entryPrefix := java.Space{Whitespace: "\t"} - if i == 0 { - entryPrefix = java.Space{Whitespace: "\n\t"} - } - d := &golang.GoModDirective{ - Ident: uuid.New(), - Prefix: entryPrefix, - Values: []*golang.GoModValue{ - {Ident: uuid.New(), Text: e.path}, - {Ident: uuid.New(), Prefix: java.SingleSpace, Text: e.version}, - }, - } - blockEntries[i] = java.RightPadded[golang.GoModStatement]{ - Element: d, - After: setIndirectComment(java.Space{}, e.indirect), - } - } - blk := &golang.GoModBlock{ - Ident: uuid.New(), - Prefix: prefix, - Keyword: "require", - BeforeLParen: java.SingleSpace, - Entries: blockEntries, - BeforeRParen: java.Space{}, - } - return java.RightPadded[golang.GoModStatement]{ - Element: blk, - After: java.Space{Whitespace: "\n"}, - } -} - -// goVersionAtLeast reports whether the go.mod's `go` directive is >= the -// given major.minor. Absent or unparseable versions are treated as recent -// (the toolchain default), so the separate-indirect-block form is used. -func goVersionAtLeast(gm *golang.GoMod, major, minor int) bool { - for _, st := range gm.Statements { - d, ok := st.Element.(*golang.GoModDirective) - if !ok || d.Keyword != "go" || len(d.Values) == 0 { - continue - } - parts := strings.SplitN(d.Values[0].Text, ".", 3) - if len(parts) < 2 { - return true - } - gMaj, err1 := strconv.Atoi(parts[0]) - gMin, err2 := strconv.Atoi(parts[1]) - if err1 != nil || err2 != nil { - return true - } - return gMaj > major || (gMaj == major && gMin >= minor) - } - return true -} - -// headerInsertIndex returns the index just after the last leading header -// directive (module / go / toolchain) in stmts, the natural spot to insert a -// require block when none exists yet. -func headerInsertIndex(stmts []java.RightPadded[golang.GoModStatement]) int { - idx := 0 - for i, st := range stmts { - if d, ok := st.Element.(*golang.GoModDirective); ok { - switch d.Keyword { - case "module", "go", "toolchain": - idx = i + 1 - } - } - } - return idx -} - -// computeTidySet computes the exact go.mod require set at recipe time by -// delegating to the pure-Java module resolver over RPC: it sends the current -// go.mod text, the imports scanned from the project's .go files, and the module -// path, and receives back the direct/indirect require set. All dependency -// metadata (go.mod/zip) is fetched on the host through the CLI HttpSender, so no -// network egress originates from this peer. -// -// Returns ok=false when no resolver is installed (running outside the CLI) or -// resolution could not complete (truly offline + cold cache, or a private -// module), so the caller falls back to the LST-only set, which PRESERVES the -// existing require/// indirect block rather than dropping unconfirmed deps. -func (v *goModTidyEditor) computeTidySet(gm *golang.GoMod, separateIndirect bool, p any) ([]reqEntry, bool) { - ctx, ok := p.(*recipe.ExecutionContext) - if !ok { - return nil, false - } - resolve := TidyResolverFrom(ctx) - if resolve == nil { - return nil, false - } - - content := printer.PrintGoMod(gm) - rs, ok := resolve(content, v.curImports, v.curModulePath, separateIndirect) - if !ok || !rs.Complete { - return nil, false - } - - entries := make([]reqEntry, 0, len(rs.Direct)+len(rs.Indirect)) - for mod, ver := range rs.Direct { - entries = append(entries, reqEntry{mod, ver, false}) - } - for mod, ver := range rs.Indirect { - entries = append(entries, reqEntry{mod, ver, true}) - } - return entries, true -} - -// directModules returns the set of required module paths that are directly -// imported by some .go file belonging to the module currently being edited. -func (v *goModTidyEditor) directModules() map[string]bool { - direct := map[string]bool{} - for _, imp := range v.curImports { - if internal.IsStdlib(imp) || internal.IsLocal(imp, v.curModulePath) { - continue - } - if m := providingModule(imp, v.curRequireMods); m != "" { - direct[m] = true - } - } - return direct -} - -// moduleOf returns the (path, version) of a require entry directive. -func moduleOf(st golang.GoModStatement) (string, string) { - d, ok := st.(*golang.GoModDirective) - if !ok || len(d.Values) == 0 { - return "", "" - } - path := d.Values[0].Text - version := "" - if len(d.Values) > 1 { - version = d.Values[1].Text - } - return path, version -} - -// setIndirectComment reconstructs an entry's trailing After so that it -// carries (or omits) a `// indirect` line comment, preserving any other -// trailing comments. The line always ends in a newline. -func setIndirectComment(after java.Space, indirect bool) java.Space { - var others []java.Comment - for _, c := range after.Comments { - if strings.TrimSpace(c.Text) == "// indirect" { - continue - } - others = append(others, c) - } - if indirect { - others = append(others, java.Comment{Kind: java.LineComment, Text: "// indirect", Suffix: "\n"}) - return java.Space{Whitespace: " ", Comments: others} - } - if len(others) > 0 { - return java.Space{Whitespace: " ", Comments: others} - } - return java.Space{Whitespace: "\n"} -} - -// providingModule returns the longest declared module path that is a prefix -// of importPath (the module that provides the imported package), or "". -func providingModule(importPath string, requireMods map[string]bool) string { - best := "" - for m := range requireMods { - if importPath == m || strings.HasPrefix(importPath, m+"/") { - if len(m) > len(best) { - best = m - } - } - } - return best -} diff --git a/rewrite-go/pkg/recipe/golang/go_mod_tidy_scope_test.go b/rewrite-go/pkg/recipe/golang/go_mod_tidy_scope_test.go deleted file mode 100644 index 5fad94a3df8..00000000000 --- a/rewrite-go/pkg/recipe/golang/go_mod_tidy_scope_test.go +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package golang - -import "testing" - -func hasImport(imps []string, want string) bool { - for _, i := range imps { - if i == want { - return true - } - } - return false -} - -func TestOwnedImportsScopesByModule(t *testing.T) { - acc := &tidyAcc{ - goModDirs: map[string]bool{"": true, "internal/tools": true}, - modulePathByDir: map[string]string{"": "example.com/root", "internal/tools": "example.com/root/internal/tools"}, - requireModsByDir: map[string]map[string]bool{}, - fileImports: map[string][]string{ - "main.go": {"github.com/spf13/cobra"}, - "internal/tools/tools.go": {"github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"}, - "internal/helper/helper.go": {"github.com/root/own"}, // belongs to root (no nested go.mod here) - }, - } - ed := &goModTidyEditor{acc: acc} - - root := ed.ownedImports("") - if !hasImport(root, "github.com/spf13/cobra") || !hasImport(root, "github.com/root/own") { - t.Errorf("root module should own main.go and internal/helper imports; got %v", root) - } - if hasImport(root, "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway") { - t.Errorf("root module must NOT own the nested internal/tools import; got %v", root) - } - - tools := ed.ownedImports("internal/tools") - if !hasImport(tools, "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway") { - t.Errorf("internal/tools module should own tools.go import; got %v", tools) - } - if hasImport(tools, "github.com/spf13/cobra") { - t.Errorf("internal/tools module must NOT own root imports; got %v", tools) - } -} diff --git a/rewrite-go/test/go_mod_tidy_recipe_test.go b/rewrite-go/test/go_mod_tidy_recipe_test.go deleted file mode 100644 index 728535cc2c3..00000000000 --- a/rewrite-go/test/go_mod_tidy_recipe_test.go +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://docs.moderne.io/licensing/moderne-source-available-license - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package test - -import ( - "testing" - - "github.com/openrewrite/rewrite/rewrite-go/pkg/parser" - "github.com/openrewrite/rewrite/rewrite-go/pkg/printer" - "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe" - golangrecipe "github.com/openrewrite/rewrite/rewrite-go/pkg/recipe/golang" - "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" -) - -// runTidy drives GoModTidy's scan->edit flow the way the RPC engine does: -// scan every .go file plus the go.mod into the accumulator, then run the -// editor over the go.mod. Returns the printed go.mod. -func runTidy(t *testing.T, goMod string, goFiles map[string]string) string { - t.Helper() - r := &golangrecipe.GoModTidy{} - ctx := recipe.NewExecutionContext() - acc := r.InitialValue(ctx) - - scanner := r.Scanner(acc) - p := parser.NewGoParser() - for path, content := range goFiles { - cu, err := p.Parse(path, content) - if err != nil { - t.Fatalf("parse %s: %v", path, err) - } - scanner.Visit(cu, ctx) - } - gm, err := parser.ParseGoModFile("go.mod", goMod) - if err != nil { - t.Fatalf("parse go.mod: %v", err) - } - scanner.Visit(gm, ctx) - - editor := r.EditorWithData(acc) - res := editor.Visit(gm, ctx) - return printer.PrintGoMod(res.(*golang.GoMod)) -} - -// Perturbation: a directly-imported module wrongly marked `// indirect` -// should have the comment removed. -func TestTidyRemovesWrongIndirect(t *testing.T) { - before := "module example.com/foo\n\ngo 1.21\n\nrequire github.com/a/b v1.0.0 // indirect\n" - want := "module example.com/foo\n\ngo 1.21\n\nrequire github.com/a/b v1.0.0\n" - goFiles := map[string]string{ - "main.go": "package main\n\nimport \"github.com/a/b\"\n\nfunc main() { _ = b.X }\n", - } - if got := runTidy(t, before, goFiles); got != want { - t.Errorf("\nwant: %q\ngot: %q", want, got) - } -} - -// Perturbation: a module not imported anywhere, missing its `// indirect` -// marker, should get one added. -func TestTidyAddsMissingIndirect(t *testing.T) { - // go >= 1.17: tidy splits the directly-imported dep into its own require - // and the unused one into a separate indirect require. - before := "module example.com/foo\n\ngo 1.21\n\nrequire (\n\tgithub.com/a/b v1.0.0\n\tgithub.com/c/d v1.5.0\n)\n" - want := "module example.com/foo\n\ngo 1.21\n\nrequire github.com/a/b v1.0.0\n\nrequire github.com/c/d v1.5.0 // indirect\n" - goFiles := map[string]string{ - "main.go": "package main\n\nimport \"github.com/a/b\"\n\nfunc main() { _ = b.X }\n", - } - if got := runTidy(t, before, goFiles); got != want { - t.Errorf("\nwant: %q\ngot: %q", want, got) - } -} - -// Perturbation: out-of-order require entries should be sorted by module path. -func TestTidySortsRequireBlock(t *testing.T) { - before := "module example.com/foo\n\ngo 1.21\n\nrequire (\n\tgithub.com/c/d v1.5.0\n\tgithub.com/a/b v1.0.0\n)\n" - want := "module example.com/foo\n\ngo 1.21\n\nrequire (\n\tgithub.com/a/b v1.0.0\n\tgithub.com/c/d v1.5.0\n)\n" - goFiles := map[string]string{ - "main.go": "package main\n\nimport (\n\t\"github.com/a/b\"\n\t\"github.com/c/d\"\n)\n\nfunc main() { _, _ = b.X, d.Y }\n", - } - if got := runTidy(t, before, goFiles); got != want { - t.Errorf("\nwant: %q\ngot: %q", want, got) - } -}