From bde1f965954c61b209916c7d6869f2d8c93d7fdb Mon Sep 17 00:00:00 2001 From: David Flanagan Date: Wed, 17 Dec 2025 10:54:38 +0000 Subject: [PATCH 1/4] mod: add local module replacement support with robust path resolution Add support for local path replacements in module.cue files, similar to Go's replace directive. This allows replacing module dependencies with local directories using paths like "./local-dep" or "../sibling". Key changes: - Add modreplace package for handling local path replacements - Add localreg.go to wrap Registry with replacement support - Update schema.cue to allow empty version for replace-only deps - Compute absolute paths at creation time instead of using os.Getwd() - Add comprehensive tests including script tests for various scenarios The path resolution now requires either: - A filesystem implementing module.OSRootFS, or - An absolute directory path This eliminates fragile reliance on os.Getwd() which could fail in containers, tests, or when the working directory changes. Signed-off-by: David Flanagan --- cmd/cue/cmd/modtidy.go | 5 +- .../testdata/script/modreplace_local.txtar | 53 +++ .../script/modreplace_local_commands.txtar | 43 +++ .../script/modreplace_local_missing.txtar | 22 ++ .../script/modreplace_local_nested.txtar | 47 +++ .../script/modreplace_local_sibling.txtar | 55 +++ .../testdata/script/modreplace_remote.txtar | 54 +++ cue/load/instances.go | 22 +- cue/load/loader_test.go | 2 +- internal/lsp/cache/module.go | 2 +- internal/mod/modfiledata/modfile.go | 83 +++++ internal/mod/modload/localreg.go | 92 +++++ internal/mod/modload/tidy.go | 78 +++- internal/mod/modload/update.go | 6 +- internal/mod/modpkgload/import.go | 2 + internal/mod/modpkgload/pkgload_test.go | 2 +- internal/mod/modreplace/local.go | 157 ++++++++ internal/mod/modreplace/local_test.go | 350 ++++++++++++++++++ internal/mod/modrequirements/requirements.go | 38 +- .../mod/modrequirements/requirements_test.go | 6 +- mod/modfile/schema.cue | 22 +- 21 files changed, 1099 insertions(+), 42 deletions(-) create mode 100644 cmd/cue/cmd/testdata/script/modreplace_local.txtar create mode 100644 cmd/cue/cmd/testdata/script/modreplace_local_commands.txtar create mode 100644 cmd/cue/cmd/testdata/script/modreplace_local_missing.txtar create mode 100644 cmd/cue/cmd/testdata/script/modreplace_local_nested.txtar create mode 100644 cmd/cue/cmd/testdata/script/modreplace_local_sibling.txtar create mode 100644 cmd/cue/cmd/testdata/script/modreplace_remote.txtar create mode 100644 internal/mod/modload/localreg.go create mode 100644 internal/mod/modreplace/local.go create mode 100644 internal/mod/modreplace/local_test.go diff --git a/cmd/cue/cmd/modtidy.go b/cmd/cue/cmd/modtidy.go index 479ca77dd0e..ecea6ba74d4 100644 --- a/cmd/cue/cmd/modtidy.go +++ b/cmd/cue/cmd/modtidy.go @@ -25,6 +25,7 @@ import ( "cuelang.org/go/internal/mod/modload" "cuelang.org/go/mod/modfile" + "cuelang.org/go/mod/module" ) func newModTidyCmd(c *Command) *cobra.Command { @@ -62,10 +63,10 @@ func runModTidy(cmd *Command, args []string) error { return err } if flagCheck.Bool(cmd) { - err := modload.CheckTidy(ctx, os.DirFS(modRoot), ".", reg) + err := modload.CheckTidy(ctx, module.OSDirFS(modRoot), ".", reg) return suggestModCommand(err) } - mf, err := modload.Tidy(ctx, os.DirFS(modRoot), ".", reg) + mf, err := modload.Tidy(ctx, module.OSDirFS(modRoot), ".", reg) if err != nil { return suggestModCommand(err) } diff --git a/cmd/cue/cmd/testdata/script/modreplace_local.txtar b/cmd/cue/cmd/testdata/script/modreplace_local.txtar new file mode 100644 index 00000000000..cf2becbbd1f --- /dev/null +++ b/cmd/cue/cmd/testdata/script/modreplace_local.txtar @@ -0,0 +1,53 @@ +# Test local path replace directives + +# The main module references a dependency that is replaced with a local path. +# The local-dep directory contains the replacement module. + +# Test that cue mod tidy works with local replacements +exec cue mod tidy +cmp cue.mod/module.cue want-module.cue + +# Test that cue eval uses the local replacement +exec cue eval . +cmp stdout want-eval.txt + +# Test that cue export also works +exec cue export . +stdout '"from local replacement"' + +-- cue.mod/module.cue -- +module: "example.com/main@v0" +language: version: "v0.9.0" + +deps: { + "example.com/dep@v0": { + v: "v0.1.0" + replace: "./local-dep" + } +} +-- main.cue -- +package main + +import "example.com/dep@v0:lib" + +output: lib.value +-- local-dep/cue.mod/module.cue -- +module: "example.com/dep@v0" +language: version: "v0.9.0" +-- local-dep/lib.cue -- +package lib + +value: "from local replacement" +-- want-eval.txt -- +output: "from local replacement" +-- want-module.cue -- +module: "example.com/main@v0" +language: { + version: "v0.9.0" +} +deps: { + "example.com/dep@v0": { + v: "v0.1.0" + replace: "./local-dep" + } +} diff --git a/cmd/cue/cmd/testdata/script/modreplace_local_commands.txtar b/cmd/cue/cmd/testdata/script/modreplace_local_commands.txtar new file mode 100644 index 00000000000..89b5cd54720 --- /dev/null +++ b/cmd/cue/cmd/testdata/script/modreplace_local_commands.txtar @@ -0,0 +1,43 @@ +# Test that various cue commands work with local replacements + +# Test cue mod tidy +exec cue mod tidy + +# Test cue eval +exec cue eval . +stdout 'output: "from local"' + +# Test cue export +exec cue export . +stdout '"from local"' + +# Test cue vet (should pass validation) +exec cue vet . + +# Test cue def (show definitions) +exec cue def . +stdout 'output:' + +-- cue.mod/module.cue -- +module: "example.com/main@v0" +language: version: "v0.9.0" + +deps: { + "example.com/dep@v0": { + v: "v0.1.0" + replace: "./local-dep" + } +} +-- main.cue -- +package main + +import "example.com/dep@v0:lib" + +output: lib.value +-- local-dep/cue.mod/module.cue -- +module: "example.com/dep@v0" +language: version: "v0.9.0" +-- local-dep/lib.cue -- +package lib + +value: "from local" diff --git a/cmd/cue/cmd/testdata/script/modreplace_local_missing.txtar b/cmd/cue/cmd/testdata/script/modreplace_local_missing.txtar new file mode 100644 index 00000000000..c668e11c22d --- /dev/null +++ b/cmd/cue/cmd/testdata/script/modreplace_local_missing.txtar @@ -0,0 +1,22 @@ +# Test error handling when local replacement directory does not exist + +# cue mod tidy should fail when the replacement directory is missing +! exec cue mod tidy +stderr 'does not exist' + +-- cue.mod/module.cue -- +module: "example.com/main@v0" +language: version: "v0.9.0" + +deps: { + "example.com/missing@v0": { + v: "v0.1.0" + replace: "./nonexistent-dir" + } +} +-- main.cue -- +package main + +import "example.com/missing@v0:lib" + +output: lib.value diff --git a/cmd/cue/cmd/testdata/script/modreplace_local_nested.txtar b/cmd/cue/cmd/testdata/script/modreplace_local_nested.txtar new file mode 100644 index 00000000000..402757632ab --- /dev/null +++ b/cmd/cue/cmd/testdata/script/modreplace_local_nested.txtar @@ -0,0 +1,47 @@ +# Test local path replace directives with nested dependencies +# The local-dep module has its own dependencies that must be resolved. + +# Test that cue mod tidy works with local replacements that have dependencies +exec cue mod tidy +cmp cue.mod/module.cue want-module.cue + +# Test that cue eval uses the local replacement and its dependencies +exec cue eval . +cmp stdout want-eval.txt + +-- cue.mod/module.cue -- +module: "example.com/main@v0" +language: version: "v0.9.0" + +deps: { + "example.com/dep@v0": { + v: "v0.1.0" + replace: "./local-dep" + } +} +-- main.cue -- +package main + +import "example.com/dep@v0:lib" + +output: lib.value +-- local-dep/cue.mod/module.cue -- +module: "example.com/dep@v0" +language: version: "v0.9.0" +-- local-dep/lib.cue -- +package lib + +value: "from local with deps" +-- want-eval.txt -- +output: "from local with deps" +-- want-module.cue -- +module: "example.com/main@v0" +language: { + version: "v0.9.0" +} +deps: { + "example.com/dep@v0": { + v: "v0.1.0" + replace: "./local-dep" + } +} diff --git a/cmd/cue/cmd/testdata/script/modreplace_local_sibling.txtar b/cmd/cue/cmd/testdata/script/modreplace_local_sibling.txtar new file mode 100644 index 00000000000..a0dd8904bef --- /dev/null +++ b/cmd/cue/cmd/testdata/script/modreplace_local_sibling.txtar @@ -0,0 +1,55 @@ +# Test local path replace directives with sibling directory (../) + +# Create the sibling directory structure +mkdir sibling-dep +mkdir sibling-dep/cue.mod +cp sibling-module.cue sibling-dep/cue.mod/module.cue +cp sibling-lib.cue sibling-dep/lib.cue + +# Run from the main directory +cd main + +# Test that cue mod tidy works with sibling path replacements +exec cue mod tidy +cmp cue.mod/module.cue want-module.cue + +# Test that cue eval uses the sibling replacement +exec cue eval . +cmp stdout want-eval.txt + +-- sibling-module.cue -- +module: "example.com/sibling@v0" +language: version: "v0.9.0" +-- sibling-lib.cue -- +package lib + +value: "from sibling directory" +-- main/cue.mod/module.cue -- +module: "example.com/main@v0" +language: version: "v0.9.0" + +deps: { + "example.com/sibling@v0": { + v: "v0.1.0" + replace: "../sibling-dep" + } +} +-- main/main.cue -- +package main + +import "example.com/sibling@v0:lib" + +output: lib.value +-- main/want-eval.txt -- +output: "from sibling directory" +-- main/want-module.cue -- +module: "example.com/main@v0" +language: { + version: "v0.9.0" +} +deps: { + "example.com/sibling@v0": { + v: "v0.1.0" + replace: "../sibling-dep" + } +} diff --git a/cmd/cue/cmd/testdata/script/modreplace_remote.txtar b/cmd/cue/cmd/testdata/script/modreplace_remote.txtar new file mode 100644 index 00000000000..6d142d4603d --- /dev/null +++ b/cmd/cue/cmd/testdata/script/modreplace_remote.txtar @@ -0,0 +1,54 @@ +# Test remote module replace directives +# A module can be replaced with a different module from the registry. + +# Test that cue mod tidy works with remote replacements +exec cue mod tidy +cmp cue.mod/module.cue want-module.cue + +# Test that cue eval uses the replacement module +exec cue eval . +cmp stdout want-eval.txt + +-- cue.mod/module.cue -- +module: "example.com/main@v0" +language: version: "v0.9.0" + +deps: { + "example.com/original@v0": { + v: "v0.1.0" + replace: "example.com/replacement@v0.1.0" + } +} +-- main.cue -- +package main + +import "example.com/original@v0:lib" + +output: lib.value +-- _registry/example.com_original_v0.1.0/cue.mod/module.cue -- +module: "example.com/original@v0" +language: version: "v0.9.0" +-- _registry/example.com_original_v0.1.0/lib.cue -- +package lib + +value: "from original (should not see this)" +-- _registry/example.com_replacement_v0.1.0/cue.mod/module.cue -- +module: "example.com/replacement@v0" +language: version: "v0.9.0" +-- _registry/example.com_replacement_v0.1.0/lib.cue -- +package lib + +value: "from replacement module" +-- want-eval.txt -- +output: "from replacement module" +-- want-module.cue -- +module: "example.com/main@v0" +language: { + version: "v0.9.0" +} +deps: { + "example.com/original@v0": { + v: "v0.1.0" + replace: "example.com/replacement@v0.1.0" + } +} diff --git a/cue/load/instances.go b/cue/load/instances.go index 635c457c89d..1674f7d3bc9 100644 --- a/cue/load/instances.go +++ b/cue/load/instances.go @@ -218,7 +218,10 @@ func loadAbsPackage( ip := ast.ParseImportPath(pkg) ip.Version = semver.Major(mv.Version()) - pkgs := loadPackages(ctx, cfg, mf, loc, []string{ip.String()}, tg) + pkgs, err := loadPackages(ctx, cfg, mf, loc, []string{ip.String()}, tg) + if err != nil { + return "", nil, err + } return ip.String(), pkgs, nil } @@ -268,7 +271,7 @@ func loadPackagesFromArgs( }, slices.Sorted(maps.Keys(pkgPaths)), tg, - ), nil + ) } func loadPackages( @@ -278,20 +281,27 @@ func loadPackages( mainModLoc module.SourceLoc, pkgPaths []string, tg *tagger, -) *modpkgload.Packages { +) (*modpkgload.Packages, error) { mainModPath := mainMod.QualifiedModule() + // Wrap the registry to handle local path replacements. + // This allows cue eval/export to read requirements from local modules. + reg, err := modload.NewLocalReplacementRegistry(cfg.Registry, mainModLoc, mainMod.Replacements()) + if err != nil { + return nil, err + } reqs := modrequirements.NewRequirements( mainModPath, - cfg.Registry, + reg, mainMod.DepVersions(), mainMod.DefaultMajorVersions(), + mainMod.Replacements(), ) return modpkgload.LoadPackages( ctx, mainModPath, mainModLoc, reqs, - cfg.Registry, + reg, pkgPaths, func(pkgPath string, mod module.Version, fsys fs.FS, mf modimports.ModuleFile) bool { if !cfg.Tools && strings.HasSuffix(mf.FilePath, "_tool.cue") { @@ -322,7 +332,7 @@ func loadPackages( } return true }, - ) + ), nil } func isAbsVersionPackage(p string) bool { diff --git a/cue/load/loader_test.go b/cue/load/loader_test.go index dffff0bdf8a..c38d423ab25 100644 --- a/cue/load/loader_test.go +++ b/cue/load/loader_test.go @@ -75,7 +75,7 @@ module: conflicting values 123 and "" (mismatched types int and string): module: conflicting values 123 and string (mismatched types int and string): $CWD/testdata/badmod/cue.mod/module.cue:2:9 cuelang.org/go/mod/modfile/schema.cue:56:12 - cuelang.org/go/mod/modfile/schema.cue:98:12 + cuelang.org/go/mod/modfile/schema.cue:104:12 path: "" module: "" root: "" diff --git a/internal/lsp/cache/module.go b/internal/lsp/cache/module.go index fa4f3ef0104..f9b80a1f93b 100644 --- a/internal/lsp/cache/module.go +++ b/internal/lsp/cache/module.go @@ -308,7 +308,7 @@ func (m *Module) loadDirtyPackages() (*modpkgload.Packages, error) { // 2. Load all the packages found modPath := m.modFile.QualifiedModule() - reqs := modrequirements.NewRequirements(modPath, w.registry, m.modFile.DepVersions(), m.modFile.DefaultMajorVersions()) + reqs := modrequirements.NewRequirements(modPath, w.registry, m.modFile.DepVersions(), m.modFile.DefaultMajorVersions(), m.modFile.Replacements()) rootUri := m.rootURI ctx := context.Background() loc := module.SourceLoc{ diff --git a/internal/mod/modfiledata/modfile.go b/internal/mod/modfiledata/modfile.go index b6cc3e7605e..ebb8802241c 100644 --- a/internal/mod/modfiledata/modfile.go +++ b/internal/mod/modfiledata/modfile.go @@ -61,6 +61,9 @@ type File struct { // defaultMajorVersions maps from module base path (the path // without its major version) to the major version default for that path. defaultMajorVersions map[string]string + + // replacements maps from module path (with major version) to its replacement. + replacements map[string]Replacement } // QualifiedModule returns the fully qualified module path @@ -116,6 +119,18 @@ type Language struct { type Dep struct { Version string `json:"v"` Default bool `json:"default,omitempty"` + Replace string `json:"replace,omitempty"` +} + +// Replacement represents a processed replace directive. +// Either New or LocalPath will be set, but not both. +type Replacement struct { + // Old is the module being replaced. + Old module.Version + // New is the replacement module version (for remote replacements). + New module.Version + // LocalPath is set for local path replacements (starts with ./ or ../). + LocalPath string } // Init initializes the private dependency-related fields of f from @@ -158,12 +173,32 @@ func (mf *File) init(strict bool) error { versionByModule := make(map[string]module.Version) var versions []module.Version defaultMajorVersions := make(map[string]string) + replacements := make(map[string]Replacement) if mainPath != "" { // The main module is always the default for its own major version. defaultMajorVersions[mainPath] = mainMajor } // Check that major versions match dependency versions. for m, dep := range mf.Deps { + // Handle replace directives + if dep.Replace != "" { + repl, err := parseReplacement(m, dep.Replace, strict) + if err != nil { + return err + } + replacements[m] = repl + } + + // If version is empty and there's a replace, this is a version-independent + // replacement - we don't add it to the versions list but still track the replacement. + if dep.Version == "" { + if dep.Replace == "" { + return fmt.Errorf("module %q has no version and no replacement", m) + } + // Version-independent replacement - don't add to versions list + continue + } + vers, err := module.NewVersion(m, dep.Version) if err != nil { return fmt.Errorf("cannot make version from module %q, version %q: %v", m, dep.Version, err) @@ -193,13 +228,54 @@ func (mf *File) init(strict bool) error { if len(defaultMajorVersions) == 0 { defaultMajorVersions = nil } + if len(replacements) == 0 { + replacements = nil + } mf.versions = versions[:len(versions):len(versions)] slices.SortFunc(mf.versions, module.Version.Compare) mf.versionByModule = versionByModule mf.defaultMajorVersions = defaultMajorVersions + mf.replacements = replacements return nil } +// parseReplacement parses a replace directive value and returns a Replacement. +// The replace value can be either: +// - A local file path starting with "./" or "../" +// - A remote module path with version (e.g., "other.com/bar@v1.0.0") +func parseReplacement(oldPath, replace string, strict bool) (Replacement, error) { + isLocal := strings.HasPrefix(replace, "./") || strings.HasPrefix(replace, "../") + + if strict && isLocal { + return Replacement{}, fmt.Errorf("local path replacement %q not allowed in strict mode", replace) + } + + // Parse the old module path to create a module.Version. We use an empty + // version string because the replacement applies to all versions of the + // module (unlike Go, CUE replacements don't support version-specific replacements). + oldVers, err := module.NewVersion(oldPath, "") + if err != nil { + return Replacement{}, fmt.Errorf("invalid module path %q in replace directive: %v", oldPath, err) + } + + repl := Replacement{ + Old: oldVers, + } + + if isLocal { + repl.LocalPath = replace + } else { + // Parse as module@version + newVers, err := module.ParseVersion(replace) + if err != nil { + return Replacement{}, fmt.Errorf("invalid replacement %q: must be local path (./... or ../...) or module@version: %v", replace, err) + } + repl.New = newVers + } + + return repl, nil +} + // MajorVersion returns the major version of the module, // not including the "@". // If there is no module (which can happen when [ParseLegacy] @@ -226,6 +302,13 @@ func (f *File) DefaultMajorVersions() map[string]string { return f.defaultMajorVersions } +// Replacements returns the map of module replacements. +// The map is keyed by module path (with major version, e.g., "foo.com/bar@v0"). +// The caller should not modify the returned map. +func (f *File) Replacements() map[string]Replacement { + return f.replacements +} + // ModuleForImportPath returns the module that should contain the given // import path and reports whether the module was found. // It does not check to see if the import path actually exists within the module. diff --git a/internal/mod/modload/localreg.go b/internal/mod/modload/localreg.go new file mode 100644 index 00000000000..8438cfd59ca --- /dev/null +++ b/internal/mod/modload/localreg.go @@ -0,0 +1,92 @@ +// Copyright 2025 CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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 modload + +import ( + "context" + + "cuelang.org/go/internal/mod/modfiledata" + "cuelang.org/go/internal/mod/modreplace" + "cuelang.org/go/mod/module" +) + +// localReplacementRegistry wraps a Registry to handle local path replacements. +// When Requirements or Fetch is called for a module that has a local path +// replacement, it uses the modreplace.LocalReplacements helper to resolve +// the local path instead of delegating to the underlying registry. +type localReplacementRegistry struct { + underlying Registry + localReplace *modreplace.LocalReplacements + replacements map[string]modfiledata.Replacement +} + +// NewLocalReplacementRegistry creates a new registry that wraps the given registry +// and handles module replacements (both local path and remote module replacements). +// If there are no replacements, it returns the original registry unchanged. +// +// Returns an error if local replacements exist but the main module location cannot +// be resolved to an absolute path. +func NewLocalReplacementRegistry(reg Registry, mainModuleLoc module.SourceLoc, replacements map[string]modfiledata.Replacement) (Registry, error) { + if len(replacements) == 0 { + return reg, nil + } + // Create local replacements helper (may be nil if no local paths) + lr, err := modreplace.NewLocalReplacements(mainModuleLoc, replacements) + if err != nil { + return nil, err + } + return &localReplacementRegistry{ + underlying: reg, + localReplace: lr, + replacements: replacements, + }, nil +} + +// Requirements implements modrequirements.Registry. +// For modules with local path replacements, it reads requirements from the +// local module's cue.mod/module.cue file. For all other modules (including +// remote replacements), it delegates to the underlying registry. +func (r *localReplacementRegistry) Requirements(ctx context.Context, m module.Version) ([]module.Version, error) { + // Check if this module has a local path replacement + if localPath := r.localReplace.LocalPathFor(m.Path()); localPath != "" { + return r.localReplace.FetchRequirements(localPath) + } + // For non-local modules, delegate to underlying registry. + // Note: remote replacements are handled by cueModSummary in requirements.go + return r.underlying.Requirements(ctx, m) +} + +// Fetch implements modpkgload.Registry. +// For modules with local path replacements, it returns a SourceLoc pointing +// to the local directory. For remote replacements, it fetches the replacement +// module. For non-replaced modules, it delegates to the underlying registry. +func (r *localReplacementRegistry) Fetch(ctx context.Context, m module.Version) (module.SourceLoc, error) { + // Check if this module has a local path replacement + if localPath := r.localReplace.LocalPathFor(m.Path()); localPath != "" { + return r.localReplace.FetchSourceLoc(localPath) + } + // Check if this is a remote replacement - fetch the replacement module + if repl, ok := r.replacements[m.Path()]; ok && repl.New.IsValid() { + return r.underlying.Fetch(ctx, repl.New) + } + return r.underlying.Fetch(ctx, m) +} + +// ModuleVersions implements Registry. +// This always delegates to the underlying registry since local modules don't +// have versions listed in a registry. +func (r *localReplacementRegistry) ModuleVersions(ctx context.Context, mpath string) ([]string, error) { + return r.underlying.ModuleVersions(ctx, mpath) +} diff --git a/internal/mod/modload/tidy.go b/internal/mod/modload/tidy.go index ddd4b5a6395..8fe7ae58f77 100644 --- a/internal/mod/modload/tidy.go +++ b/internal/mod/modload/tidy.go @@ -14,6 +14,7 @@ import ( "strings" "cuelang.org/go/internal/buildattr" + "cuelang.org/go/internal/mod/modfiledata" "cuelang.org/go/internal/mod/modimports" "cuelang.org/go/internal/mod/modpkgload" "cuelang.org/go/internal/mod/modrequirements" @@ -65,8 +66,21 @@ func tidy(ctx context.Context, fsys fs.FS, modRoot string, reg Registry, checkTi if err != nil { return nil, err } + + mainModuleLoc := module.SourceLoc{ + FS: fsys, + Dir: modRoot, + } + + // Wrap the registry to handle local path replacements. + // This allows cue mod tidy to read requirements from local modules. + wrappedReg, err := NewLocalReplacementRegistry(reg, mainModuleLoc, mf.Replacements()) + if err != nil { + return nil, err + } + // TODO check that module path is well formed etc - origRs := modrequirements.NewRequirements(mf.QualifiedModule(), reg, mf.DepVersions(), mf.DefaultMajorVersions()) + origRs := modrequirements.NewRequirements(mf.QualifiedModule(), wrappedReg, mf.DepVersions(), mf.DefaultMajorVersions(), mf.Replacements()) // Note: we can ignore build tags and the fact that we might // have _tool.cue and _test.cue files, because we want to include // all of those, but we do need to consider @ignore() attributes. @@ -75,13 +89,10 @@ func tidy(ctx context.Context, fsys fs.FS, modRoot string, reg Registry, checkTi return nil, err } ld := &loader{ - mainModule: mainModuleVersion, - registry: reg, - mainModuleLoc: module.SourceLoc{ - FS: fsys, - Dir: modRoot, - }, - checkTidy: checkTidy, + mainModule: mainModuleVersion, + registry: wrappedReg, + mainModuleLoc: mainModuleLoc, + checkTidy: checkTidy, } rs, pkgs, err := ld.resolveDependencies(ctx, rootPkgPaths, origRs) @@ -127,7 +138,24 @@ func equalRequirements(rs0, rs1 *modrequirements.Requirements) bool { // Note that we clone the slice to not modify rs1's internal slice in-place. rs1RootMods := slices.DeleteFunc(slices.Clone(rs1.RootModules()), module.Version.IsLocal) return slices.Equal(rs0.RootModules(), rs1RootMods) && - maps.Equal(rs0.DefaultMajorVersions(), rs1.DefaultMajorVersions()) + maps.Equal(rs0.DefaultMajorVersions(), rs1.DefaultMajorVersions()) && + equalReplacements(rs0.Replacements(), rs1.Replacements()) +} + +func equalReplacements(r0, r1 map[string]modfiledata.Replacement) bool { + if len(r0) != len(r1) { + return false + } + for k, v0 := range r0 { + v1, ok := r1[k] + if !ok { + return false + } + if v0.LocalPath != v1.LocalPath || v0.New != v1.New || v0.Old != v1.Old { + return false + } + } + return true } func readModuleFile(fsys fs.FS, modRoot string) (module.Version, *modfile.File, error) { @@ -159,15 +187,33 @@ func modfileFromRequirements(old *modfile.File, rs *modrequirements.Requirements Source: old.Source, Custom: old.Custom, } + + // First, preserve all replace directives from the original file. + // Replace directives are preserved during tidy operations (like Go). + for path, dep := range old.Deps { + if dep.Replace != "" { + mf.Deps[path] = &modfile.Dep{ + Version: dep.Version, + Default: dep.Default, + Replace: dep.Replace, + } + } + } + + // Then add/update dependencies from requirements. defaults := rs.DefaultMajorVersions() for _, v := range rs.RootModules() { if v.IsLocal() { continue } - mf.Deps[v.Path()] = &modfile.Dep{ - Version: v.Version(), - Default: defaults[v.BasePath()] == semver.Major(v.Version()), + dep := mf.Deps[v.Path()] + if dep == nil { + dep = &modfile.Dep{} + mf.Deps[v.Path()] = dep } + dep.Version = v.Version() + dep.Default = defaults[v.BasePath()] == semver.Major(v.Version()) + // Note: Replace field is preserved from the first loop if it existed } return mf } @@ -418,7 +464,7 @@ func (ld *loader) updateRoots(ctx context.Context, rs *modrequirements.Requireme // graph so that we can update those roots to be consistent with other // requirements. - rs = modrequirements.NewRequirements(ld.mainModule.Path(), ld.registry, roots, rs.DefaultMajorVersions()) + rs = modrequirements.NewRequirements(ld.mainModule.Path(), ld.registry, roots, rs.DefaultMajorVersions(), rs.Replacements()) var err error mg, err = rs.Graph(ctx) if err != nil { @@ -508,7 +554,7 @@ func (ld *loader) updateRoots(ctx context.Context, rs *modrequirements.Requireme // preserve its cached ModuleGraph (if any). return rs, nil } - return modrequirements.NewRequirements(ld.mainModule.Path(), ld.registry, roots, rs.DefaultMajorVersions()), nil + return modrequirements.NewRequirements(ld.mainModule.Path(), ld.registry, roots, rs.DefaultMajorVersions(), rs.Replacements()), nil } // resolveMissingImports returns a set of modules that could be added as @@ -629,7 +675,7 @@ func (ld *loader) tidyRoots(ctx context.Context, old *modrequirements.Requiremen queued[pkg] = true } slices.SortFunc(roots, module.Version.Compare) - tidy := modrequirements.NewRequirements(ld.mainModule.Path(), ld.registry, roots, old.DefaultMajorVersions()) + tidy := modrequirements.NewRequirements(ld.mainModule.Path(), ld.registry, roots, old.DefaultMajorVersions(), old.Replacements()) for len(queue) > 0 { roots = tidy.RootModules() @@ -661,7 +707,7 @@ func (ld *loader) tidyRoots(ctx context.Context, old *modrequirements.Requiremen if tidyRoots := tidy.RootModules(); len(roots) > len(tidyRoots) { slices.SortFunc(roots, module.Version.Compare) - tidy = modrequirements.NewRequirements(ld.mainModule.Path(), ld.registry, roots, tidy.DefaultMajorVersions()) + tidy = modrequirements.NewRequirements(ld.mainModule.Path(), ld.registry, roots, tidy.DefaultMajorVersions(), tidy.Replacements()) } } diff --git a/internal/mod/modload/update.go b/internal/mod/modload/update.go index 5d6d63f2d5b..9749258d536 100644 --- a/internal/mod/modload/update.go +++ b/internal/mod/modload/update.go @@ -41,7 +41,7 @@ func UpdateVersions(ctx context.Context, fsys fs.FS, modRoot string, reg Registr if err != nil { return nil, err } - rs := modrequirements.NewRequirements(mf.QualifiedModule(), reg, mf.DepVersions(), mf.DefaultMajorVersions()) + rs := modrequirements.NewRequirements(mf.QualifiedModule(), reg, mf.DepVersions(), mf.DefaultMajorVersions(), mf.Replacements()) mversions, err := resolveUpdateVersions(ctx, reg, rs, mainModuleVersion, versions) if err != nil { return nil, err @@ -80,7 +80,7 @@ func UpdateVersions(ctx context.Context, fsys fs.FS, modRoot string, reg Registr } newVersions = slices.AppendSeq(newVersions, maps.Values(mversionsMap)) slices.SortFunc(newVersions, module.Version.Compare) - rs = modrequirements.NewRequirements(mf.QualifiedModule(), reg, newVersions, mf.DefaultMajorVersions()) + rs = modrequirements.NewRequirements(mf.QualifiedModule(), reg, newVersions, mf.DefaultMajorVersions(), mf.Replacements()) g, err = rs.Graph(ctx) if err != nil { return nil, fmt.Errorf("cannot determine new module graph: %v", err) @@ -99,7 +99,7 @@ func UpdateVersions(ctx context.Context, fsys fs.FS, modRoot string, reg Registr finalVersions = append(finalVersions, v) } } - rs = modrequirements.NewRequirements(mf.QualifiedModule(), reg, finalVersions, mf.DefaultMajorVersions()) + rs = modrequirements.NewRequirements(mf.QualifiedModule(), reg, finalVersions, mf.DefaultMajorVersions(), mf.Replacements()) return modfileFromRequirements(mf, rs), nil } diff --git a/internal/mod/modpkgload/import.go b/internal/mod/modpkgload/import.go index 1b430ccffab..e00cc14facf 100644 --- a/internal/mod/modpkgload/import.go +++ b/internal/mod/modpkgload/import.go @@ -342,6 +342,8 @@ func (pkgs *Packages) fetch(ctx context.Context, mod module.Version) (loc module if mod == pkgs.mainModuleVersion { return pkgs.mainModuleLoc, true, nil } + // The registry handles both local path replacements and remote module + // replacements via the localReplacementRegistry wrapper. loc, err = pkgs.registry.Fetch(ctx, mod) return loc, false, err } diff --git a/internal/mod/modpkgload/pkgload_test.go b/internal/mod/modpkgload/pkgload_test.go index c3e15a87cf3..5bd7c8ec17f 100644 --- a/internal/mod/modpkgload/pkgload_test.go +++ b/internal/mod/modpkgload/pkgload_test.go @@ -49,7 +49,7 @@ func TestLoadPackages(t *testing.T) { qt.Assert(t, qt.IsTrue(ok)) defaultMajorVersions[p] = v } - initialRequirements := modrequirements.NewRequirements(mainModulePath, reg, moduleVersions, defaultMajorVersions) + initialRequirements := modrequirements.NewRequirements(mainModulePath, reg, moduleVersions, defaultMajorVersions, nil) rootPackages := strings.Fields(readTestFile("root-packages")) want := readTestFile("want") diff --git a/internal/mod/modreplace/local.go b/internal/mod/modreplace/local.go new file mode 100644 index 00000000000..d8beceafc95 --- /dev/null +++ b/internal/mod/modreplace/local.go @@ -0,0 +1,157 @@ +// Copyright 2025 CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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 modreplace provides utilities for handling module replacements, +// particularly local path replacements. +package modreplace + +import ( + "fmt" + "os" + "path/filepath" + + "cuelang.org/go/internal/mod/modfiledata" + "cuelang.org/go/mod/modfile" + "cuelang.org/go/mod/module" +) + +// LocalReplacements handles resolution of local path replacements. +// It provides methods to resolve local paths (starting with ./ or ../) +// to absolute filesystem paths and to fetch module information from +// those local directories. +// +// A nil *LocalReplacements is valid and all methods are nil-safe, +// returning appropriate zero values or errors. +type LocalReplacements struct { + mainModuleLoc module.SourceLoc + replacements map[string]modfiledata.Replacement + resolvedRoot string // Absolute OS path to the main module root, computed at creation time +} + +// NewLocalReplacements creates a new LocalReplacements instance for the given +// main module location and replacement map. Returns (nil, nil) if there are no local +// path replacements (i.e., all replacements are remote module replacements). +// +// Returns an error if the main module location cannot be resolved to an absolute +// path (i.e., the filesystem doesn't implement OSRootFS and Dir is not absolute). +// +// The caller must not modify the replacements map after calling this function. +func NewLocalReplacements(mainModuleLoc module.SourceLoc, replacements map[string]modfiledata.Replacement) (*LocalReplacements, error) { + hasLocal := false + for _, r := range replacements { + if r.LocalPath != "" { + hasLocal = true + break + } + } + if !hasLocal { + return nil, nil + } + + // Resolve the root path at creation time to avoid fragile os.Getwd() calls later. + var resolvedRoot string + if osFS, ok := mainModuleLoc.FS.(module.OSRootFS); ok { + // OSRootFS provides an absolute OS root path + resolvedRoot = filepath.Join(osFS.OSRoot(), mainModuleLoc.Dir) + } else if filepath.IsAbs(mainModuleLoc.Dir) { + // Dir is already an absolute path + resolvedRoot = mainModuleLoc.Dir + } else { + return nil, fmt.Errorf("cannot resolve local replacements: filesystem does not provide OS root and directory %q is not absolute", mainModuleLoc.Dir) + } + + return &LocalReplacements{ + mainModuleLoc: mainModuleLoc, + replacements: replacements, + resolvedRoot: resolvedRoot, + }, nil +} + +// LocalPathFor returns the local path replacement for the given module path +// (which should include the major version, e.g., "example.com/foo@v0"). +// Returns an empty string if no local replacement exists for the module. +func (lr *LocalReplacements) LocalPathFor(modulePath string) string { + if lr == nil { + return "" + } + if repl, ok := lr.replacements[modulePath]; ok && repl.LocalPath != "" { + return repl.LocalPath + } + return "" +} + +// ResolveToAbsPath resolves a local replacement path (e.g., "./local-dep" or +// "../sibling") to an absolute OS filesystem path, relative to the main module's +// location. +func (lr *LocalReplacements) ResolveToAbsPath(localPath string) (string, error) { + if lr == nil { + return "", fmt.Errorf("cannot resolve local path: no local replacements configured") + } + return filepath.Clean(filepath.Join(lr.resolvedRoot, localPath)), nil +} + +// FetchSourceLoc returns a SourceLoc for a local path replacement. +// The returned SourceLoc points to the local directory and can be used +// to read module source files. +func (lr *LocalReplacements) FetchSourceLoc(localPath string) (module.SourceLoc, error) { + absPath, err := lr.ResolveToAbsPath(localPath) + if err != nil { + return module.SourceLoc{}, err + } + + // Validate the path exists and is a directory + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return module.SourceLoc{}, fmt.Errorf("replacement directory %q does not exist", localPath) + } + return module.SourceLoc{}, err + } + if !info.IsDir() { + return module.SourceLoc{}, fmt.Errorf("replacement path %q is not a directory", localPath) + } + + return module.SourceLoc{ + FS: module.OSDirFS(absPath), + Dir: ".", + }, nil +} + +// FetchRequirements reads the dependencies from a local module's cue.mod/module.cue +// file. Returns nil (not an error) if the local module has no module.cue file, +// indicating the module has no dependencies. +func (lr *LocalReplacements) FetchRequirements(localPath string) ([]module.Version, error) { + absPath, err := lr.ResolveToAbsPath(localPath) + if err != nil { + return nil, err + } + + // Read the module.cue file from the local path + modFilePath := filepath.Join(absPath, "cue.mod", "module.cue") + data, err := os.ReadFile(modFilePath) + if err != nil { + if os.IsNotExist(err) { + // No module.cue means the local module has no dependencies + return nil, nil + } + return nil, fmt.Errorf("cannot read module file from local replacement %q: %v", localPath, err) + } + + mf, err := modfile.ParseNonStrict(data, modFilePath) + if err != nil { + return nil, fmt.Errorf("cannot parse module file from local replacement %q: %v", localPath, err) + } + + return mf.DepVersions(), nil +} diff --git a/internal/mod/modreplace/local_test.go b/internal/mod/modreplace/local_test.go new file mode 100644 index 00000000000..9ca71f018c6 --- /dev/null +++ b/internal/mod/modreplace/local_test.go @@ -0,0 +1,350 @@ +// Copyright 2025 CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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 modreplace + +import ( + "os" + "path/filepath" + "testing" + + "cuelang.org/go/internal/mod/modfiledata" + "cuelang.org/go/mod/module" +) + +func TestNewLocalReplacements(t *testing.T) { + tests := []struct { + name string + replacements map[string]modfiledata.Replacement + wantNil bool + }{ + { + name: "nil replacements", + replacements: nil, + wantNil: true, + }, + { + name: "empty replacements", + replacements: map[string]modfiledata.Replacement{}, + wantNil: true, + }, + { + name: "only remote replacements", + replacements: map[string]modfiledata.Replacement{ + "example.com/foo@v0": { + Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), + New: module.MustNewVersion("example.com/bar@v0", "v0.2.0"), + }, + }, + wantNil: true, + }, + { + name: "has local replacement", + replacements: map[string]modfiledata.Replacement{ + "example.com/foo@v0": { + Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), + LocalPath: "./local-foo", + }, + }, + wantNil: false, + }, + { + name: "mixed local and remote", + replacements: map[string]modfiledata.Replacement{ + "example.com/foo@v0": { + Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), + LocalPath: "./local-foo", + }, + "example.com/bar@v0": { + Old: module.MustNewVersion("example.com/bar@v0", "v0.1.0"), + New: module.MustNewVersion("example.com/baz@v0", "v0.2.0"), + }, + }, + wantNil: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use an absolute path so the test doesn't fail due to non-OSRootFS + tmpDir := t.TempDir() + lr, err := NewLocalReplacements(module.SourceLoc{ + FS: module.OSDirFS(tmpDir), + Dir: ".", + }, tt.replacements) + if err != nil { + t.Fatalf("NewLocalReplacements() error = %v", err) + } + if (lr == nil) != tt.wantNil { + t.Errorf("NewLocalReplacements() returned nil=%v, want nil=%v", lr == nil, tt.wantNil) + } + }) + } +} + +func TestLocalPathFor(t *testing.T) { + tmpDir := t.TempDir() + replacements := map[string]modfiledata.Replacement{ + "example.com/local@v0": { + Old: module.MustNewVersion("example.com/local@v0", "v0.1.0"), + LocalPath: "./local-dep", + }, + "example.com/remote@v0": { + Old: module.MustNewVersion("example.com/remote@v0", "v0.1.0"), + New: module.MustNewVersion("example.com/other@v0", "v0.2.0"), + }, + } + + lr, err := NewLocalReplacements(module.SourceLoc{ + FS: module.OSDirFS(tmpDir), + Dir: ".", + }, replacements) + if err != nil { + t.Fatalf("NewLocalReplacements() error = %v", err) + } + + tests := []struct { + modulePath string + want string + }{ + {"example.com/local@v0", "./local-dep"}, + {"example.com/remote@v0", ""}, + {"example.com/unknown@v0", ""}, + } + + for _, tt := range tests { + t.Run(tt.modulePath, func(t *testing.T) { + got := lr.LocalPathFor(tt.modulePath) + if got != tt.want { + t.Errorf("LocalPathFor(%q) = %q, want %q", tt.modulePath, got, tt.want) + } + }) + } + + // Test nil receiver + var nilLR *LocalReplacements + if got := nilLR.LocalPathFor("example.com/foo@v0"); got != "" { + t.Errorf("nil.LocalPathFor() = %q, want empty string", got) + } +} + +func TestResolveToAbsPath(t *testing.T) { + // Create a temporary directory structure for testing + tmpDir := t.TempDir() + + replacements := map[string]modfiledata.Replacement{ + "example.com/foo@v0": { + Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), + LocalPath: "./local-dep", + }, + } + + // Test with OSRootFS (using OSDirFS) + lr, err := NewLocalReplacements(module.SourceLoc{ + FS: module.OSDirFS(tmpDir), + Dir: ".", + }, replacements) + if err != nil { + t.Fatalf("NewLocalReplacements() error = %v", err) + } + + absPath, err := lr.ResolveToAbsPath("./local-dep") + if err != nil { + t.Fatalf("ResolveToAbsPath() error = %v", err) + } + + expected := filepath.Join(tmpDir, "local-dep") + if absPath != expected { + t.Errorf("ResolveToAbsPath() = %q, want %q", absPath, expected) + } + + // Test parent directory path + absPath, err = lr.ResolveToAbsPath("../sibling") + if err != nil { + t.Fatalf("ResolveToAbsPath() error = %v", err) + } + + expected = filepath.Clean(filepath.Join(tmpDir, "..", "sibling")) + if absPath != expected { + t.Errorf("ResolveToAbsPath(../sibling) = %q, want %q", absPath, expected) + } + + // Test nil receiver + var nilLR *LocalReplacements + _, err = nilLR.ResolveToAbsPath("./foo") + if err == nil { + t.Error("nil.ResolveToAbsPath() expected error, got nil") + } +} + +func TestFetchSourceLoc(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + localDepDir := filepath.Join(tmpDir, "local-dep") + if err := os.MkdirAll(localDepDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a file (not directory) for error testing + filePath := filepath.Join(tmpDir, "not-a-dir") + if err := os.WriteFile(filePath, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + replacements := map[string]modfiledata.Replacement{ + "example.com/foo@v0": { + Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), + LocalPath: "./local-dep", + }, + } + + lr, err := NewLocalReplacements(module.SourceLoc{ + FS: module.OSDirFS(tmpDir), + Dir: ".", + }, replacements) + if err != nil { + t.Fatalf("NewLocalReplacements() error = %v", err) + } + + // Test successful fetch + loc, err := lr.FetchSourceLoc("./local-dep") + if err != nil { + t.Fatalf("FetchSourceLoc() error = %v", err) + } + if loc.Dir != "." { + t.Errorf("FetchSourceLoc().Dir = %q, want \".\"", loc.Dir) + } + + // Test missing directory + _, err = lr.FetchSourceLoc("./nonexistent") + if err == nil { + t.Error("FetchSourceLoc(nonexistent) expected error, got nil") + } + + // Test path is file, not directory + _, err = lr.FetchSourceLoc("./not-a-dir") + if err == nil { + t.Error("FetchSourceLoc(file) expected error, got nil") + } +} + +func TestFetchRequirements(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + + // Create local-dep with module.cue + localDepDir := filepath.Join(tmpDir, "local-dep", "cue.mod") + if err := os.MkdirAll(localDepDir, 0755); err != nil { + t.Fatal(err) + } + moduleCue := `module: "example.com/dep@v0" +language: version: "v0.9.0" +deps: { + "example.com/transitive@v0": v: "v0.1.0" +} +` + if err := os.WriteFile(filepath.Join(localDepDir, "module.cue"), []byte(moduleCue), 0644); err != nil { + t.Fatal(err) + } + + // Create local-nodeps without module.cue + localNoDepsDir := filepath.Join(tmpDir, "local-nodeps") + if err := os.MkdirAll(localNoDepsDir, 0755); err != nil { + t.Fatal(err) + } + + // Create local-invalid with invalid module.cue + localInvalidDir := filepath.Join(tmpDir, "local-invalid", "cue.mod") + if err := os.MkdirAll(localInvalidDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(localInvalidDir, "module.cue"), []byte("invalid cue"), 0644); err != nil { + t.Fatal(err) + } + + replacements := map[string]modfiledata.Replacement{ + "example.com/foo@v0": { + Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), + LocalPath: "./local-dep", + }, + } + + lr, err := NewLocalReplacements(module.SourceLoc{ + FS: module.OSDirFS(tmpDir), + Dir: ".", + }, replacements) + if err != nil { + t.Fatalf("NewLocalReplacements() error = %v", err) + } + + // Test with dependencies + deps, err := lr.FetchRequirements("./local-dep") + if err != nil { + t.Fatalf("FetchRequirements() error = %v", err) + } + if len(deps) != 1 { + t.Errorf("FetchRequirements() returned %d deps, want 1", len(deps)) + } + if len(deps) > 0 && deps[0].Path() != "example.com/transitive@v0" { + t.Errorf("deps[0].Path() = %q, want example.com/transitive@v0", deps[0].Path()) + } + + // Test without module.cue (no deps) + deps, err = lr.FetchRequirements("./local-nodeps") + if err != nil { + t.Fatalf("FetchRequirements(nodeps) error = %v", err) + } + if deps != nil { + t.Errorf("FetchRequirements(nodeps) = %v, want nil", deps) + } + + // Test with invalid module.cue + _, err = lr.FetchRequirements("./local-invalid") + if err == nil { + t.Error("FetchRequirements(invalid) expected error, got nil") + } +} + +func TestNewLocalReplacementsPathResolutionError(t *testing.T) { + replacements := map[string]modfiledata.Replacement{ + "example.com/foo@v0": { + Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), + LocalPath: "./local-dep", + }, + } + + // Test that NewLocalReplacements returns an error when the filesystem + // doesn't implement OSRootFS and the directory is not absolute. + // Use os.DirFS which doesn't implement OSRootFS. + _, err := NewLocalReplacements(module.SourceLoc{ + FS: os.DirFS("/"), + Dir: "relative-dir", + }, replacements) + if err == nil { + t.Error("NewLocalReplacements() expected error for non-absolute path with non-OSRootFS, got nil") + } + + // Test that it succeeds with an absolute path even without OSRootFS + tmpDir := t.TempDir() + lr, err := NewLocalReplacements(module.SourceLoc{ + FS: os.DirFS("/"), + Dir: tmpDir, // absolute path + }, replacements) + if err != nil { + t.Fatalf("NewLocalReplacements() with absolute path error = %v", err) + } + if lr == nil { + t.Error("NewLocalReplacements() with absolute path returned nil") + } +} diff --git a/internal/mod/modrequirements/requirements.go b/internal/mod/modrequirements/requirements.go index bf0a1d00c90..ed0bc8fb0ff 100644 --- a/internal/mod/modrequirements/requirements.go +++ b/internal/mod/modrequirements/requirements.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "cuelang.org/go/cue/ast" + "cuelang.org/go/internal/mod/modfiledata" "cuelang.org/go/internal/mod/mvs" "cuelang.org/go/internal/mod/semver" "cuelang.org/go/internal/par" @@ -43,6 +44,10 @@ type Requirements struct { // in the roots. defaultMajorVersions map[string]majorVersionDefault + // replacements maps module paths to their replacements. + // Keyed by module path with major version (e.g., "foo.com/bar@v0"). + replacements map[string]modfiledata.Replacement + graphOnce sync.Once // guards writes to (but not reads from) graph graph atomic.Pointer[cachedGraph] } @@ -70,9 +75,11 @@ type cachedGraph struct { // mdule paths, if any have been specified. For example {"foo.com/bar": "v0"} specifies // that the default major version for the module `foo.com/bar` is `v0`. // -// The caller must not modify rootModules or defaultMajorVersions after passing +// The replacements map holds module replacements, keyed by module path with major version. +// +// The caller must not modify rootModules, defaultMajorVersions, or replacements after passing // them to NewRequirements. -func NewRequirements(mainModulePath string, reg Registry, rootModules []module.Version, defaultMajorVersions map[string]string) *Requirements { +func NewRequirements(mainModulePath string, reg Registry, rootModules []module.Version, defaultMajorVersions map[string]string, replacements map[string]modfiledata.Replacement) *Requirements { mainModuleVersion := module.MustNewVersion(mainModulePath, "") // TODO add direct, so we can tell which modules are directly used by the // main module. @@ -89,6 +96,7 @@ func NewRequirements(mainModulePath string, reg Registry, rootModules []module.V mainModuleVersion: mainModuleVersion, rootModules: rootModules, maxRootVersion: make(map[string]string, len(rootModules)), + replacements: replacements, } for i, m := range rootModules { if i > 0 { @@ -113,6 +121,7 @@ func (rs *Requirements) WithDefaultMajorVersions(defaults map[string]string) *Re mainModuleVersion: rs.mainModuleVersion, rootModules: rs.rootModules, maxRootVersion: rs.maxRootVersion, + replacements: rs.replacements, } // Initialize graph and graphOnce in rs1 to mimic their state in rs. // We can't copy the sync.Once, so if it's already triggered, we'll @@ -208,6 +217,19 @@ func (rs *Requirements) DefaultMajorVersion(mpath string) (string, MajorVersionD } } +// Replacement returns the replacement for the given module path, if any. +// The mpath should include the major version (e.g., "foo.com/bar@v0"). +func (rs *Requirements) Replacement(mpath string) (modfiledata.Replacement, bool) { + r, ok := rs.replacements[mpath] + return r, ok +} + +// Replacements returns the map of all module replacements. +// The caller should not modify the returned map. +func (rs *Requirements) Replacements() map[string]modfiledata.Replacement { + return rs.replacements +} + // rootModules returns the set of root modules of the graph, sorted and capped to // length. It may contain duplicates, and may contain multiple versions for a // given module path. @@ -259,11 +281,19 @@ type ModuleGraph struct { // // The caller must not modify the returned summary. func (rs *Requirements) cueModSummary(ctx context.Context, m module.Version) (*modFileSummary, error) { - require, err := rs.registry.Requirements(ctx, m) + // Apply replacement if there is one for this module. + // For remote module replacements, fetch requirements from the replacement module. + // For local path replacements, the registry will handle fetching from the local path. + fetchModule := m + if repl, ok := rs.replacements[m.Path()]; ok && repl.New.IsValid() { + // Remote module replacement - fetch requirements from the replacement + fetchModule = repl.New + } + + require, err := rs.registry.Requirements(ctx, fetchModule) if err != nil { return nil, err } - // TODO account for replacements, exclusions, etc. return &modFileSummary{ module: m, require: require, diff --git a/internal/mod/modrequirements/requirements_test.go b/internal/mod/modrequirements/requirements_test.go index 6845bc1a91c..d36aee8bad2 100644 --- a/internal/mod/modrequirements/requirements_test.go +++ b/internal/mod/modrequirements/requirements_test.go @@ -66,7 +66,7 @@ language: version: "v0.8.0" rootVersion := mustParseVersion("example.com@v0") - rs := NewRequirements(rootVersion.Path(), reg, versions("foo.com/bar/hello@v0.2.3"), nil) + rs := NewRequirements(rootVersion.Path(), reg, versions("foo.com/bar/hello@v0.2.3"), nil, nil) v, ok := rs.RootSelected(rootVersion.Path()) qt.Assert(t, qt.IsTrue(ok)) @@ -120,7 +120,7 @@ deps: "bar.com@v0": v: "v0.0.2" // doesn't exist rs := NewRequirements(rootVersion.Path(), reg, versions( "bar.com@v0.0.2", "foo.com/bar/hello@v0.2.3", - ), nil) + ), nil, nil) _, err := rs.Graph(ctx) qt.Assert(t, qt.ErrorMatches(err, `bar.com@v0.0.2: module bar.com@v0.0.2: module not found`)) qt.Assert(t, qt.ErrorAs(err, new(*mvs.BuildListError[module.Version]))) @@ -136,7 +136,7 @@ func TestRequirementsWithDefaultMajorVersions(t *testing.T) { "foo.com/bar/hello@v0.2.3", ), map[string]string{ "bar.com": "v1", - }) + }, nil) qt.Assert(t, qt.DeepEquals(rs.DefaultMajorVersions(), map[string]string{ "bar.com": "v1", })) diff --git a/mod/modfile/schema.cue b/mod/modfile/schema.cue index c6d5692441c..b17ad6b9eb8 100644 --- a/mod/modfile/schema.cue +++ b/mod/modfile/schema.cue @@ -80,9 +80,9 @@ versions: "v0.9.0-alpha.0": { #Dep: { // v indicates the minimum required version of the module. This can - // be null if the version is unknown and the module entry is only - // present to be replaced. - v!: #Semver | null + // be null or empty if the version is unknown and the module entry + // is only present to be replaced. + v!: #Semver | null | "" // default indicates this module is used as a default in case more // than one major version is specified for the same module path. @@ -90,6 +90,12 @@ versions: "v0.9.0-alpha.0": { // there is more than one major version for that path and default is // not set for exactly one of them. default?: bool + + // replace specifies a replacement for this module. + // It can be either: + // - A local file path starting with "./" or "../" + // - A remote module path with version (e.g., "other.com/bar@v1.0.0") + replace?: string } // #Module constrains a module path. The major version indicator is @@ -112,8 +118,14 @@ versions: "v0.9.0-alpha.0": { // The module declaration is required. module!: #Module - // No null versions, because no replacements yet. - #Dep: v!: #Semver + // In strict mode, versions must be present and local path replacements + // are not allowed in published modules. + #Dep: { + v!: #Semver + // Local path replacements (starting with ./ or ../) are not allowed + // in published modules. Remote module replacements are allowed. + replace?: !~"^\\.\\.?/" + } } // #Source describes a source of truth for a module's content. From d3af39b5b014f9fd12216e47f352cbfc50aecaaf Mon Sep 17 00:00:00 2001 From: David Flanagan Date: Wed, 17 Dec 2025 13:14:28 +0000 Subject: [PATCH 2/4] mod: address PR review feedback for local replacement support Address feedback from PR #4214 review: - Fix misleading comment in requirements.go about local path replacement handling (local replacements are handled by localReplacementRegistry wrapper, not in cueModSummary) - Clarify schema.cue documentation: use "version-independent replacement" instead of "version is unknown" - Use module.Version.Equal() method instead of != operator in tidy.go for idiomatic comparison - Add clarifying comment explaining intentional orphan replacement preservation (matches Go's go mod tidy behavior) - Add explicit validation rejecting absolute paths in parseReplacement() with clear error messages for both Unix and Windows paths - Add test for self-referencing local replacements to verify the system handles edge cases gracefully Signed-off-by: David Flanagan --- internal/mod/modfiledata/modfile.go | 9 ++++ internal/mod/modload/tidy.go | 7 ++- internal/mod/modreplace/local_test.go | 55 ++++++++++++++++++++ internal/mod/modrequirements/requirements.go | 7 +-- mod/modfile/schema.cue | 4 +- 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/internal/mod/modfiledata/modfile.go b/internal/mod/modfiledata/modfile.go index ebb8802241c..f579997fc79 100644 --- a/internal/mod/modfiledata/modfile.go +++ b/internal/mod/modfiledata/modfile.go @@ -246,6 +246,15 @@ func (mf *File) init(strict bool) error { func parseReplacement(oldPath, replace string, strict bool) (Replacement, error) { isLocal := strings.HasPrefix(replace, "./") || strings.HasPrefix(replace, "../") + // Reject absolute paths - they must use relative paths starting with ./ or ../ + // Check for Unix-style absolute paths (/foo) and Windows-style (C:\foo or C:/foo) + if len(replace) > 0 && replace[0] == '/' { + return Replacement{}, fmt.Errorf("absolute path replacement %q not allowed; use relative path starting with ./ or ../", replace) + } + if len(replace) >= 3 && replace[1] == ':' && (replace[2] == '/' || replace[2] == '\\') { + return Replacement{}, fmt.Errorf("absolute path replacement %q not allowed; use relative path starting with ./ or ../", replace) + } + if strict && isLocal { return Replacement{}, fmt.Errorf("local path replacement %q not allowed in strict mode", replace) } diff --git a/internal/mod/modload/tidy.go b/internal/mod/modload/tidy.go index 8fe7ae58f77..d1347ed8e5f 100644 --- a/internal/mod/modload/tidy.go +++ b/internal/mod/modload/tidy.go @@ -151,7 +151,7 @@ func equalReplacements(r0, r1 map[string]modfiledata.Replacement) bool { if !ok { return false } - if v0.LocalPath != v1.LocalPath || v0.New != v1.New || v0.Old != v1.Old { + if v0.LocalPath != v1.LocalPath || !v0.New.Equal(v1.New) || !v0.Old.Equal(v1.Old) { return false } } @@ -189,7 +189,10 @@ func modfileFromRequirements(old *modfile.File, rs *modrequirements.Requirements } // First, preserve all replace directives from the original file. - // Replace directives are preserved during tidy operations (like Go). + // Replace directives are preserved during tidy operations, matching Go's behavior. + // This means replace directives may remain even if their target dependency is + // no longer needed - this is intentional to allow users to maintain replacements + // for dependencies that may be re-added later. for path, dep := range old.Deps { if dep.Replace != "" { mf.Deps[path] = &modfile.Dep{ diff --git a/internal/mod/modreplace/local_test.go b/internal/mod/modreplace/local_test.go index 9ca71f018c6..17454a11a69 100644 --- a/internal/mod/modreplace/local_test.go +++ b/internal/mod/modreplace/local_test.go @@ -316,6 +316,61 @@ deps: { } } +// TestLocalReplacementWithSelfReference tests behavior when a local replacement +// has a dependency that references the module being replaced. This verifies +// that the system handles such edge cases gracefully without infinite recursion. +// Note: Local replacements are only processed at the main module level, so +// true circular replacement chains are not possible by design. +func TestLocalReplacementWithSelfReference(t *testing.T) { + tmpDir := t.TempDir() + + // Create local-a which has a dependency on example.com/foo (the module being replaced) + localADir := filepath.Join(tmpDir, "local-a", "cue.mod") + if err := os.MkdirAll(localADir, 0755); err != nil { + t.Fatal(err) + } + // This local module depends on example.com/foo - the module it's replacing! + // This tests that we don't get into infinite recursion when resolving deps. + moduleCue := `module: "example.com/local-a@v0" +language: version: "v0.9.0" +deps: { + "example.com/foo@v0": v: "v0.1.0" +} +` + if err := os.WriteFile(filepath.Join(localADir, "module.cue"), []byte(moduleCue), 0644); err != nil { + t.Fatal(err) + } + + replacements := map[string]modfiledata.Replacement{ + "example.com/foo@v0": { + Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), + LocalPath: "./local-a", + }, + } + + lr, err := NewLocalReplacements(module.SourceLoc{ + FS: module.OSDirFS(tmpDir), + Dir: ".", + }, replacements) + if err != nil { + t.Fatalf("NewLocalReplacements() error = %v", err) + } + + // FetchRequirements should succeed and return the deps from local-a, + // even though those deps include the module being replaced. + // The actual resolution of circular deps happens at a higher level. + deps, err := lr.FetchRequirements("./local-a") + if err != nil { + t.Fatalf("FetchRequirements() error = %v", err) + } + if len(deps) != 1 { + t.Errorf("FetchRequirements() returned %d deps, want 1", len(deps)) + } + if len(deps) > 0 && deps[0].Path() != "example.com/foo@v0" { + t.Errorf("deps[0].Path() = %q, want example.com/foo@v0", deps[0].Path()) + } +} + func TestNewLocalReplacementsPathResolutionError(t *testing.T) { replacements := map[string]modfiledata.Replacement{ "example.com/foo@v0": { diff --git a/internal/mod/modrequirements/requirements.go b/internal/mod/modrequirements/requirements.go index ed0bc8fb0ff..c4f53170a29 100644 --- a/internal/mod/modrequirements/requirements.go +++ b/internal/mod/modrequirements/requirements.go @@ -282,11 +282,12 @@ type ModuleGraph struct { // The caller must not modify the returned summary. func (rs *Requirements) cueModSummary(ctx context.Context, m module.Version) (*modFileSummary, error) { // Apply replacement if there is one for this module. - // For remote module replacements, fetch requirements from the replacement module. - // For local path replacements, the registry will handle fetching from the local path. + // Remote module replacements are handled here by substituting the fetch target. + // Local path replacements are handled transparently by the localReplacementRegistry + // wrapper (in localreg.go) which intercepts registry.Requirements() calls. fetchModule := m if repl, ok := rs.replacements[m.Path()]; ok && repl.New.IsValid() { - // Remote module replacement - fetch requirements from the replacement + // Remote module replacement - fetch requirements from the replacement module fetchModule = repl.New } diff --git a/mod/modfile/schema.cue b/mod/modfile/schema.cue index b17ad6b9eb8..55202dd5d40 100644 --- a/mod/modfile/schema.cue +++ b/mod/modfile/schema.cue @@ -80,8 +80,8 @@ versions: "v0.9.0-alpha.0": { #Dep: { // v indicates the minimum required version of the module. This can - // be null or empty if the version is unknown and the module entry - // is only present to be replaced. + // be null or empty when the module entry is only present to specify + // a local path replacement (version-independent replacement). v!: #Semver | null | "" // default indicates this module is used as a default in case more From c8ab1e15fa288feda565c142ff19c683b4c7f86e Mon Sep 17 00:00:00 2001 From: David Flanagan Date: Wed, 17 Dec 2025 13:55:09 +0000 Subject: [PATCH 3/4] mod: improve path validation and add comprehensive tests Address remaining PR #4214 review feedback: - Improve Windows drive letter validation to verify the first character is actually a letter using unicode.IsLetter() (fixes false positives on paths like "9:\notpath") - Add UNC path rejection for \\server\share and //server/share paths - Add comprehensive unit tests for parseReplacement() covering valid local/remote paths, absolute path rejection, UNC paths, strict mode, and invalid module paths/versions - Add script test verifying replace directives are preserved when dependencies are removed during tidy Signed-off-by: David Flanagan --- .../script/modreplace_preserve_orphan.txtar | 71 ++++++ internal/mod/modfiledata/modfile.go | 12 +- internal/mod/modfiledata/modfile_test.go | 218 ++++++++++++++++++ 3 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 cmd/cue/cmd/testdata/script/modreplace_preserve_orphan.txtar create mode 100644 internal/mod/modfiledata/modfile_test.go diff --git a/cmd/cue/cmd/testdata/script/modreplace_preserve_orphan.txtar b/cmd/cue/cmd/testdata/script/modreplace_preserve_orphan.txtar new file mode 100644 index 00000000000..9af9923b563 --- /dev/null +++ b/cmd/cue/cmd/testdata/script/modreplace_preserve_orphan.txtar @@ -0,0 +1,71 @@ +# Test that replace directives are preserved when dependency is removed +# +# This tests that replace directives are intentionally preserved during tidy +# even when the dependency is no longer used (matching Go's go mod tidy behavior). +# This allows users to maintain replacements for dependencies that may be +# re-added later without losing the configuration. + +# Initial state: main.cue imports dep, module.cue has replace directive +exec cue mod tidy +exec cue eval . +stdout '"from local replacement"' + +# Remove the import from main.cue +cp _templates/main_no_import.cue main.cue + +# Run tidy - the replace directive should be preserved even though dep is unused +exec cue mod tidy +cmp cue.mod/module.cue _golden/want-module-after-tidy.cue + +# Re-add the import +cp _templates/main_with_import.cue main.cue + +# Verify the replacement still works after tidy +exec cue mod tidy +exec cue eval . +stdout '"from local replacement"' + +-- cue.mod/module.cue -- +module: "example.com/main@v0" +language: version: "v0.9.0" + +deps: { + "example.com/dep@v0": { + v: "v0.1.0" + replace: "./local-dep" + } +} +-- main.cue -- +package main + +import "example.com/dep@v0:lib" + +output: lib.value +-- _templates/main_with_import.cue -- +package main + +import "example.com/dep@v0:lib" + +output: lib.value +-- _templates/main_no_import.cue -- +package main + +output: "no dependency" +-- local-dep/cue.mod/module.cue -- +module: "example.com/dep@v0" +language: version: "v0.9.0" +-- local-dep/lib.cue -- +package lib + +value: "from local replacement" +-- _golden/want-module-after-tidy.cue -- +module: "example.com/main@v0" +language: { + version: "v0.9.0" +} +deps: { + "example.com/dep@v0": { + v: "v0.1.0" + replace: "./local-dep" + } +} diff --git a/internal/mod/modfiledata/modfile.go b/internal/mod/modfiledata/modfile.go index f579997fc79..8bc9af6e75b 100644 --- a/internal/mod/modfiledata/modfile.go +++ b/internal/mod/modfiledata/modfile.go @@ -27,6 +27,7 @@ import ( "path" "slices" "strings" + "unicode" "cuelang.org/go/cue/ast" "cuelang.org/go/internal/mod/semver" @@ -247,13 +248,18 @@ func parseReplacement(oldPath, replace string, strict bool) (Replacement, error) isLocal := strings.HasPrefix(replace, "./") || strings.HasPrefix(replace, "../") // Reject absolute paths - they must use relative paths starting with ./ or ../ - // Check for Unix-style absolute paths (/foo) and Windows-style (C:\foo or C:/foo) - if len(replace) > 0 && replace[0] == '/' { + // Check for Unix-style absolute paths (/foo) but not UNC-style (//server) + if len(replace) > 0 && replace[0] == '/' && (len(replace) < 2 || replace[1] != '/') { return Replacement{}, fmt.Errorf("absolute path replacement %q not allowed; use relative path starting with ./ or ../", replace) } - if len(replace) >= 3 && replace[1] == ':' && (replace[2] == '/' || replace[2] == '\\') { + // Check for Windows-style absolute paths (C:\foo or C:/foo) - first char must be a letter + if len(replace) >= 3 && unicode.IsLetter(rune(replace[0])) && replace[1] == ':' && (replace[2] == '/' || replace[2] == '\\') { return Replacement{}, fmt.Errorf("absolute path replacement %q not allowed; use relative path starting with ./ or ../", replace) } + // Reject UNC paths (\\server\share or //server/share) + if len(replace) >= 2 && ((replace[0] == '\\' && replace[1] == '\\') || (replace[0] == '/' && replace[1] == '/')) { + return Replacement{}, fmt.Errorf("UNC path replacement %q not allowed; use relative path starting with ./ or ../", replace) + } if strict && isLocal { return Replacement{}, fmt.Errorf("local path replacement %q not allowed in strict mode", replace) diff --git a/internal/mod/modfiledata/modfile_test.go b/internal/mod/modfiledata/modfile_test.go new file mode 100644 index 00000000000..c4512f4fec6 --- /dev/null +++ b/internal/mod/modfiledata/modfile_test.go @@ -0,0 +1,218 @@ +// Copyright 2025 CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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 modfiledata + +import ( + "strings" + "testing" +) + +func TestParseReplacement(t *testing.T) { + tests := []struct { + name string + oldPath string + replace string + strict bool + wantLocal bool // true if expecting LocalPath to be set + wantErr string // substring of expected error, empty for no error + }{ + // Valid local paths + { + name: "valid local path ./", + oldPath: "example.com/foo@v0", + replace: "./local", + wantLocal: true, + }, + { + name: "valid local path ../", + oldPath: "example.com/foo@v0", + replace: "../sibling", + wantLocal: true, + }, + { + name: "valid local path ../../", + oldPath: "example.com/foo@v0", + replace: "../../deep/path", + wantLocal: true, + }, + + // Valid remote replacements + { + name: "valid remote replacement", + oldPath: "example.com/foo@v0", + replace: "example.com/bar@v0.1.0", + wantLocal: false, + }, + + // Unix absolute paths - should be rejected + { + name: "reject Unix absolute path /foo", + oldPath: "example.com/foo@v0", + replace: "/absolute/path", + wantErr: "absolute path replacement", + }, + { + name: "reject Unix absolute path /", + oldPath: "example.com/foo@v0", + replace: "/", + wantErr: "absolute path replacement", + }, + + // Windows absolute paths - should be rejected + { + name: "reject Windows absolute path C:\\", + oldPath: "example.com/foo@v0", + replace: "C:\\windows\\path", + wantErr: "absolute path replacement", + }, + { + name: "reject Windows absolute path C:/", + oldPath: "example.com/foo@v0", + replace: "C:/windows/path", + wantErr: "absolute path replacement", + }, + { + name: "reject Windows absolute path D:\\", + oldPath: "example.com/foo@v0", + replace: "D:\\other\\drive", + wantErr: "absolute path replacement", + }, + { + name: "reject lowercase drive letter c:\\", + oldPath: "example.com/foo@v0", + replace: "c:\\windows\\path", + wantErr: "absolute path replacement", + }, + + // UNC paths - should be rejected + { + name: "reject UNC path \\\\server", + oldPath: "example.com/foo@v0", + replace: "\\\\server\\share", + wantErr: "UNC path replacement", + }, + { + name: "reject UNC path //server", + oldPath: "example.com/foo@v0", + replace: "//server/share", + wantErr: "UNC path replacement", + }, + + // Non-absolute paths that look like they could be + { + name: "not absolute: 9:\\path (digit not letter)", + oldPath: "example.com/foo@v0", + replace: "9:\\notpath", + wantLocal: false, // Will be parsed as remote (and fail version parse) + wantErr: "invalid replacement", // fails as invalid module@version + }, + { + name: "not absolute: @:\\path (symbol not letter)", + oldPath: "example.com/foo@v0", + replace: "@:\\notpath", + wantLocal: false, + wantErr: "invalid replacement", + }, + + // Strict mode + { + name: "reject local path in strict mode", + oldPath: "example.com/foo@v0", + replace: "./local", + strict: true, + wantErr: "not allowed in strict mode", + }, + { + name: "allow remote in strict mode", + oldPath: "example.com/foo@v0", + replace: "example.com/bar@v0.1.0", + strict: true, + wantLocal: false, + }, + + // Invalid module paths + { + name: "invalid old module path", + oldPath: "not-a-valid-module", + replace: "./local", + wantErr: "invalid module path", + }, + + // Invalid remote versions + { + name: "invalid version in remote replacement", + oldPath: "example.com/foo@v0", + replace: "example.com/bar@invalid", + wantErr: "invalid replacement", + }, + { + name: "missing version in remote replacement", + oldPath: "example.com/foo@v0", + replace: "example.com/bar", + wantErr: "invalid replacement", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repl, err := parseReplacement(tt.oldPath, tt.replace, tt.strict) + + if tt.wantErr != "" { + if err == nil { + t.Errorf("parseReplacement(%q, %q, %v) = %+v, want error containing %q", + tt.oldPath, tt.replace, tt.strict, repl, tt.wantErr) + return + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("parseReplacement(%q, %q, %v) error = %q, want error containing %q", + tt.oldPath, tt.replace, tt.strict, err.Error(), tt.wantErr) + } + return + } + + if err != nil { + t.Errorf("parseReplacement(%q, %q, %v) error = %v, want no error", + tt.oldPath, tt.replace, tt.strict, err) + return + } + + if tt.wantLocal { + if repl.LocalPath != tt.replace { + t.Errorf("parseReplacement(%q, %q, %v).LocalPath = %q, want %q", + tt.oldPath, tt.replace, tt.strict, repl.LocalPath, tt.replace) + } + if repl.New.IsValid() { + t.Errorf("parseReplacement(%q, %q, %v).New should be invalid for local path", + tt.oldPath, tt.replace, tt.strict) + } + } else { + if repl.LocalPath != "" { + t.Errorf("parseReplacement(%q, %q, %v).LocalPath = %q, want empty for remote", + tt.oldPath, tt.replace, tt.strict, repl.LocalPath) + } + if !repl.New.IsValid() { + t.Errorf("parseReplacement(%q, %q, %v).New should be valid for remote", + tt.oldPath, tt.replace, tt.strict) + } + } + + // Verify Old is always set correctly + if repl.Old.Path() != tt.oldPath { + t.Errorf("parseReplacement(%q, %q, %v).Old.Path() = %q, want %q", + tt.oldPath, tt.replace, tt.strict, repl.Old.Path(), tt.oldPath) + } + }) + } +} From c0fbd952f7dd6e551d721c8c9ea111074d0f2528 Mon Sep 17 00:00:00 2001 From: David Flanagan Date: Thu, 29 Jan 2026 16:38:20 +0000 Subject: [PATCH 4/4] fix: honor local replacements in mod get and LSP Signed-off-by: David Flanagan --- cmd/cue/cmd/modget.go | 3 +- .../testdata/script/modreplace_local.txtar | 7 + .../script/modreplace_local_commands.txtar | 43 -- .../script/modreplace_local_nested.txtar | 47 -- internal/lsp/cache/cache.go | 6 +- internal/lsp/cache/module.go | 12 +- internal/mod/modfiledata/modfile.go | 94 +++- internal/mod/modfiledata/modfile_test.go | 8 +- internal/mod/modload/localreg.go | 9 +- .../local.go => modload/localreplace.go} | 4 +- internal/mod/modload/tidy.go | 19 +- internal/mod/modload/update.go | 16 +- internal/mod/modreplace/local_test.go | 405 ------------------ mod/modfile/schema.cue | 4 +- 14 files changed, 125 insertions(+), 552 deletions(-) delete mode 100644 cmd/cue/cmd/testdata/script/modreplace_local_commands.txtar delete mode 100644 cmd/cue/cmd/testdata/script/modreplace_local_nested.txtar rename internal/mod/{modreplace/local.go => modload/localreplace.go} (97%) delete mode 100644 internal/mod/modreplace/local_test.go diff --git a/cmd/cue/cmd/modget.go b/cmd/cue/cmd/modget.go index 156a5d0b8b2..2691a0ba99c 100644 --- a/cmd/cue/cmd/modget.go +++ b/cmd/cue/cmd/modget.go @@ -24,6 +24,7 @@ import ( "cuelang.org/go/internal/mod/modload" "cuelang.org/go/mod/modfile" + "cuelang.org/go/mod/module" ) func newModGetCmd(c *Command) *cobra.Command { @@ -69,7 +70,7 @@ func runModGet(cmd *Command, args []string) error { if err != nil { return err } - mf, err := modload.UpdateVersions(ctx, os.DirFS(modRoot), ".", reg, args) + mf, err := modload.UpdateVersions(ctx, module.OSDirFS(modRoot), ".", reg, args) if err != nil { return suggestModCommand(err) } diff --git a/cmd/cue/cmd/testdata/script/modreplace_local.txtar b/cmd/cue/cmd/testdata/script/modreplace_local.txtar index cf2becbbd1f..8bb9a7f0f82 100644 --- a/cmd/cue/cmd/testdata/script/modreplace_local.txtar +++ b/cmd/cue/cmd/testdata/script/modreplace_local.txtar @@ -15,6 +15,13 @@ cmp stdout want-eval.txt exec cue export . stdout '"from local replacement"' +# Test that cue vet works (should pass validation) +exec cue vet . + +# Test that cue def works (show definitions) +exec cue def . +stdout 'output:' + -- cue.mod/module.cue -- module: "example.com/main@v0" language: version: "v0.9.0" diff --git a/cmd/cue/cmd/testdata/script/modreplace_local_commands.txtar b/cmd/cue/cmd/testdata/script/modreplace_local_commands.txtar deleted file mode 100644 index 89b5cd54720..00000000000 --- a/cmd/cue/cmd/testdata/script/modreplace_local_commands.txtar +++ /dev/null @@ -1,43 +0,0 @@ -# Test that various cue commands work with local replacements - -# Test cue mod tidy -exec cue mod tidy - -# Test cue eval -exec cue eval . -stdout 'output: "from local"' - -# Test cue export -exec cue export . -stdout '"from local"' - -# Test cue vet (should pass validation) -exec cue vet . - -# Test cue def (show definitions) -exec cue def . -stdout 'output:' - --- cue.mod/module.cue -- -module: "example.com/main@v0" -language: version: "v0.9.0" - -deps: { - "example.com/dep@v0": { - v: "v0.1.0" - replace: "./local-dep" - } -} --- main.cue -- -package main - -import "example.com/dep@v0:lib" - -output: lib.value --- local-dep/cue.mod/module.cue -- -module: "example.com/dep@v0" -language: version: "v0.9.0" --- local-dep/lib.cue -- -package lib - -value: "from local" diff --git a/cmd/cue/cmd/testdata/script/modreplace_local_nested.txtar b/cmd/cue/cmd/testdata/script/modreplace_local_nested.txtar deleted file mode 100644 index 402757632ab..00000000000 --- a/cmd/cue/cmd/testdata/script/modreplace_local_nested.txtar +++ /dev/null @@ -1,47 +0,0 @@ -# Test local path replace directives with nested dependencies -# The local-dep module has its own dependencies that must be resolved. - -# Test that cue mod tidy works with local replacements that have dependencies -exec cue mod tidy -cmp cue.mod/module.cue want-module.cue - -# Test that cue eval uses the local replacement and its dependencies -exec cue eval . -cmp stdout want-eval.txt - --- cue.mod/module.cue -- -module: "example.com/main@v0" -language: version: "v0.9.0" - -deps: { - "example.com/dep@v0": { - v: "v0.1.0" - replace: "./local-dep" - } -} --- main.cue -- -package main - -import "example.com/dep@v0:lib" - -output: lib.value --- local-dep/cue.mod/module.cue -- -module: "example.com/dep@v0" -language: version: "v0.9.0" --- local-dep/lib.cue -- -package lib - -value: "from local with deps" --- want-eval.txt -- -output: "from local with deps" --- want-module.cue -- -module: "example.com/main@v0" -language: { - version: "v0.9.0" -} -deps: { - "example.com/dep@v0": { - v: "v0.1.0" - replace: "./local-dep" - } -} diff --git a/internal/lsp/cache/cache.go b/internal/lsp/cache/cache.go index b96a4b0e67d..21c8d522e58 100644 --- a/internal/lsp/cache/cache.go +++ b/internal/lsp/cache/cache.go @@ -6,8 +6,7 @@ package cache import ( "cuelang.org/go/internal/lsp/fscache" - "cuelang.org/go/internal/mod/modpkgload" - "cuelang.org/go/internal/mod/modrequirements" + "cuelang.org/go/internal/mod/modload" "cuelang.org/go/mod/modconfig" ) @@ -42,6 +41,5 @@ type Cache struct { } type Registry interface { - modrequirements.Registry - modpkgload.Registry + modload.Registry } diff --git a/internal/lsp/cache/module.go b/internal/lsp/cache/module.go index f9b80a1f93b..30c72228145 100644 --- a/internal/lsp/cache/module.go +++ b/internal/lsp/cache/module.go @@ -27,6 +27,7 @@ import ( "cuelang.org/go/cue/parser" "cuelang.org/go/internal/golangorgx/gopls/protocol" "cuelang.org/go/internal/lsp/fscache" + "cuelang.org/go/internal/mod/modload" "cuelang.org/go/internal/mod/modpkgload" "cuelang.org/go/internal/mod/modrequirements" "cuelang.org/go/mod/modfile" @@ -284,8 +285,7 @@ func (m *Module) DescendantPackages(ip ast.ImportPath) []*Package { // loadDirtyPackages identifies all dirty packages within the module, // loads them and returns them. To do this, the modfile itself must be -// successfully loaded. The only non-nil error this method returns is -// if the modfile cannot be loaded. +// successfully loaded. func (m *Module) loadDirtyPackages() (*modpkgload.Packages, error) { if err := m.ReloadModule(); err != nil { return nil, err @@ -308,17 +308,21 @@ func (m *Module) loadDirtyPackages() (*modpkgload.Packages, error) { // 2. Load all the packages found modPath := m.modFile.QualifiedModule() - reqs := modrequirements.NewRequirements(modPath, w.registry, m.modFile.DepVersions(), m.modFile.DefaultMajorVersions(), m.modFile.Replacements()) rootUri := m.rootURI ctx := context.Background() loc := module.SourceLoc{ FS: w.overlayFS.IoFS(rootUri.Path()), Dir: ".", // NB can't be "" } + reg, err := modload.NewLocalReplacementRegistry(w.registry, loc, m.modFile.Replacements()) + if err != nil { + return nil, err + } + reqs := modrequirements.NewRequirements(modPath, reg, m.modFile.DepVersions(), m.modFile.DefaultMajorVersions(), m.modFile.Replacements()) // Determinism in log messages: slices.Sort(pkgPaths) w.debugLogf("%v Loading packages %v", m, pkgPaths) - loadedPkgs := modpkgload.LoadPackages(ctx, modPath, loc, reqs, w.registry, pkgPaths, nil) + loadedPkgs := modpkgload.LoadPackages(ctx, modPath, loc, reqs, reg, pkgPaths, nil) return loadedPkgs, nil } diff --git a/internal/mod/modfiledata/modfile.go b/internal/mod/modfiledata/modfile.go index 8bc9af6e75b..e0ffac9cb75 100644 --- a/internal/mod/modfiledata/modfile.go +++ b/internal/mod/modfiledata/modfile.go @@ -27,7 +27,6 @@ import ( "path" "slices" "strings" - "unicode" "cuelang.org/go/cue/ast" "cuelang.org/go/internal/mod/semver" @@ -247,19 +246,10 @@ func (mf *File) init(strict bool) error { func parseReplacement(oldPath, replace string, strict bool) (Replacement, error) { isLocal := strings.HasPrefix(replace, "./") || strings.HasPrefix(replace, "../") - // Reject absolute paths - they must use relative paths starting with ./ or ../ - // Check for Unix-style absolute paths (/foo) but not UNC-style (//server) - if len(replace) > 0 && replace[0] == '/' && (len(replace) < 2 || replace[1] != '/') { + // Reject absolute paths - must use relative paths starting with ./ or ../ + if isAbsolutePath(replace) { return Replacement{}, fmt.Errorf("absolute path replacement %q not allowed; use relative path starting with ./ or ../", replace) } - // Check for Windows-style absolute paths (C:\foo or C:/foo) - first char must be a letter - if len(replace) >= 3 && unicode.IsLetter(rune(replace[0])) && replace[1] == ':' && (replace[2] == '/' || replace[2] == '\\') { - return Replacement{}, fmt.Errorf("absolute path replacement %q not allowed; use relative path starting with ./ or ../", replace) - } - // Reject UNC paths (\\server\share or //server/share) - if len(replace) >= 2 && ((replace[0] == '\\' && replace[1] == '\\') || (replace[0] == '/' && replace[1] == '/')) { - return Replacement{}, fmt.Errorf("UNC path replacement %q not allowed; use relative path starting with ./ or ../", replace) - } if strict && isLocal { return Replacement{}, fmt.Errorf("local path replacement %q not allowed in strict mode", replace) @@ -347,3 +337,83 @@ func (f *File) ModuleForImportPath(importPath string) (module.Version, bool) { } return module.Version{}, false } + +// isAbsolutePath reports whether the given path is an absolute path +// on any supported platform (Unix or Windows). +func isAbsolutePath(p string) bool { + return strings.HasPrefix(p, "/") || isWindowsAbs(p) +} + +func isWindowsAbs(path string) bool { + if isReservedWindowsName(path) { + return true + } + volLen := windowsVolumeNameLen(path) + if volLen == 0 { + return false + } + if len(path) == volLen { + // UNC roots like \\server\share are absolute. + return len(path) >= 2 && isSlash(path[0]) && isSlash(path[1]) + } + path = path[volLen:] + return isSlash(path[0]) +} + +func isSlash(c byte) bool { + return c == '\\' || c == '/' +} + +var reservedWindowsNames = []string{ + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +} + +func isReservedWindowsName(path string) bool { + if len(path) == 0 { + return false + } + for _, reserved := range reservedWindowsNames { + if strings.EqualFold(path, reserved) { + return true + } + } + return false +} + +// windowsVolumeNameLen returns length of the leading volume name on Windows. +func windowsVolumeNameLen(path string) int { + if len(path) < 2 { + return 0 + } + // with drive letter + c := path[0] + if path[1] == ':' && (('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z')) { + return 2 + } + // UNC path + if l := len(path); l >= 5 && isSlash(path[0]) && isSlash(path[1]) && + !isSlash(path[2]) && path[2] != '.' { + // leading \\ then server name + for n := 3; n < l-1; n++ { + if isSlash(path[n]) { + n++ + // share name + if !isSlash(path[n]) { + if path[n] == '.' { + break + } + for ; n < l; n++ { + if isSlash(path[n]) { + break + } + } + return n + } + break + } + } + } + return 0 +} diff --git a/internal/mod/modfiledata/modfile_test.go b/internal/mod/modfiledata/modfile_test.go index c4512f4fec6..20077ff6229 100644 --- a/internal/mod/modfiledata/modfile_test.go +++ b/internal/mod/modfiledata/modfile_test.go @@ -96,18 +96,18 @@ func TestParseReplacement(t *testing.T) { wantErr: "absolute path replacement", }, - // UNC paths - should be rejected + // UNC paths - should be rejected as absolute paths { name: "reject UNC path \\\\server", oldPath: "example.com/foo@v0", replace: "\\\\server\\share", - wantErr: "UNC path replacement", + wantErr: "absolute path replacement", }, { name: "reject UNC path //server", oldPath: "example.com/foo@v0", replace: "//server/share", - wantErr: "UNC path replacement", + wantErr: "absolute path replacement", }, // Non-absolute paths that look like they could be @@ -115,7 +115,7 @@ func TestParseReplacement(t *testing.T) { name: "not absolute: 9:\\path (digit not letter)", oldPath: "example.com/foo@v0", replace: "9:\\notpath", - wantLocal: false, // Will be parsed as remote (and fail version parse) + wantLocal: false, // Will be parsed as remote (and fail version parse) wantErr: "invalid replacement", // fails as invalid module@version }, { diff --git a/internal/mod/modload/localreg.go b/internal/mod/modload/localreg.go index 8438cfd59ca..7ae329122ff 100644 --- a/internal/mod/modload/localreg.go +++ b/internal/mod/modload/localreg.go @@ -18,17 +18,16 @@ import ( "context" "cuelang.org/go/internal/mod/modfiledata" - "cuelang.org/go/internal/mod/modreplace" "cuelang.org/go/mod/module" ) // localReplacementRegistry wraps a Registry to handle local path replacements. // When Requirements or Fetch is called for a module that has a local path -// replacement, it uses the modreplace.LocalReplacements helper to resolve -// the local path instead of delegating to the underlying registry. +// replacement, it uses LocalReplacements to resolve the local path instead of +// delegating to the underlying registry. type localReplacementRegistry struct { underlying Registry - localReplace *modreplace.LocalReplacements + localReplace *LocalReplacements replacements map[string]modfiledata.Replacement } @@ -43,7 +42,7 @@ func NewLocalReplacementRegistry(reg Registry, mainModuleLoc module.SourceLoc, r return reg, nil } // Create local replacements helper (may be nil if no local paths) - lr, err := modreplace.NewLocalReplacements(mainModuleLoc, replacements) + lr, err := NewLocalReplacements(mainModuleLoc, replacements) if err != nil { return nil, err } diff --git a/internal/mod/modreplace/local.go b/internal/mod/modload/localreplace.go similarity index 97% rename from internal/mod/modreplace/local.go rename to internal/mod/modload/localreplace.go index d8beceafc95..a5e126a0f52 100644 --- a/internal/mod/modreplace/local.go +++ b/internal/mod/modload/localreplace.go @@ -12,9 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package modreplace provides utilities for handling module replacements, -// particularly local path replacements. -package modreplace +package modload import ( "fmt" diff --git a/internal/mod/modload/tidy.go b/internal/mod/modload/tidy.go index d1347ed8e5f..aaf791e23e1 100644 --- a/internal/mod/modload/tidy.go +++ b/internal/mod/modload/tidy.go @@ -14,7 +14,6 @@ import ( "strings" "cuelang.org/go/internal/buildattr" - "cuelang.org/go/internal/mod/modfiledata" "cuelang.org/go/internal/mod/modimports" "cuelang.org/go/internal/mod/modpkgload" "cuelang.org/go/internal/mod/modrequirements" @@ -139,23 +138,7 @@ func equalRequirements(rs0, rs1 *modrequirements.Requirements) bool { rs1RootMods := slices.DeleteFunc(slices.Clone(rs1.RootModules()), module.Version.IsLocal) return slices.Equal(rs0.RootModules(), rs1RootMods) && maps.Equal(rs0.DefaultMajorVersions(), rs1.DefaultMajorVersions()) && - equalReplacements(rs0.Replacements(), rs1.Replacements()) -} - -func equalReplacements(r0, r1 map[string]modfiledata.Replacement) bool { - if len(r0) != len(r1) { - return false - } - for k, v0 := range r0 { - v1, ok := r1[k] - if !ok { - return false - } - if v0.LocalPath != v1.LocalPath || !v0.New.Equal(v1.New) || !v0.Old.Equal(v1.Old) { - return false - } - } - return true + maps.Equal(rs0.Replacements(), rs1.Replacements()) } func readModuleFile(fsys fs.FS, modRoot string) (module.Version, *modfile.File, error) { diff --git a/internal/mod/modload/update.go b/internal/mod/modload/update.go index 9749258d536..cf55e8529da 100644 --- a/internal/mod/modload/update.go +++ b/internal/mod/modload/update.go @@ -41,8 +41,16 @@ func UpdateVersions(ctx context.Context, fsys fs.FS, modRoot string, reg Registr if err != nil { return nil, err } - rs := modrequirements.NewRequirements(mf.QualifiedModule(), reg, mf.DepVersions(), mf.DefaultMajorVersions(), mf.Replacements()) - mversions, err := resolveUpdateVersions(ctx, reg, rs, mainModuleVersion, versions) + mainModuleLoc := module.SourceLoc{ + FS: fsys, + Dir: modRoot, + } + wrappedReg, err := NewLocalReplacementRegistry(reg, mainModuleLoc, mf.Replacements()) + if err != nil { + return nil, err + } + rs := modrequirements.NewRequirements(mf.QualifiedModule(), wrappedReg, mf.DepVersions(), mf.DefaultMajorVersions(), mf.Replacements()) + mversions, err := resolveUpdateVersions(ctx, wrappedReg, rs, mainModuleVersion, versions) if err != nil { return nil, err } @@ -80,7 +88,7 @@ func UpdateVersions(ctx context.Context, fsys fs.FS, modRoot string, reg Registr } newVersions = slices.AppendSeq(newVersions, maps.Values(mversionsMap)) slices.SortFunc(newVersions, module.Version.Compare) - rs = modrequirements.NewRequirements(mf.QualifiedModule(), reg, newVersions, mf.DefaultMajorVersions(), mf.Replacements()) + rs = modrequirements.NewRequirements(mf.QualifiedModule(), wrappedReg, newVersions, mf.DefaultMajorVersions(), mf.Replacements()) g, err = rs.Graph(ctx) if err != nil { return nil, fmt.Errorf("cannot determine new module graph: %v", err) @@ -99,7 +107,7 @@ func UpdateVersions(ctx context.Context, fsys fs.FS, modRoot string, reg Registr finalVersions = append(finalVersions, v) } } - rs = modrequirements.NewRequirements(mf.QualifiedModule(), reg, finalVersions, mf.DefaultMajorVersions(), mf.Replacements()) + rs = modrequirements.NewRequirements(mf.QualifiedModule(), wrappedReg, finalVersions, mf.DefaultMajorVersions(), mf.Replacements()) return modfileFromRequirements(mf, rs), nil } diff --git a/internal/mod/modreplace/local_test.go b/internal/mod/modreplace/local_test.go deleted file mode 100644 index 17454a11a69..00000000000 --- a/internal/mod/modreplace/local_test.go +++ /dev/null @@ -1,405 +0,0 @@ -// Copyright 2025 CUE Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// 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 modreplace - -import ( - "os" - "path/filepath" - "testing" - - "cuelang.org/go/internal/mod/modfiledata" - "cuelang.org/go/mod/module" -) - -func TestNewLocalReplacements(t *testing.T) { - tests := []struct { - name string - replacements map[string]modfiledata.Replacement - wantNil bool - }{ - { - name: "nil replacements", - replacements: nil, - wantNil: true, - }, - { - name: "empty replacements", - replacements: map[string]modfiledata.Replacement{}, - wantNil: true, - }, - { - name: "only remote replacements", - replacements: map[string]modfiledata.Replacement{ - "example.com/foo@v0": { - Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), - New: module.MustNewVersion("example.com/bar@v0", "v0.2.0"), - }, - }, - wantNil: true, - }, - { - name: "has local replacement", - replacements: map[string]modfiledata.Replacement{ - "example.com/foo@v0": { - Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), - LocalPath: "./local-foo", - }, - }, - wantNil: false, - }, - { - name: "mixed local and remote", - replacements: map[string]modfiledata.Replacement{ - "example.com/foo@v0": { - Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), - LocalPath: "./local-foo", - }, - "example.com/bar@v0": { - Old: module.MustNewVersion("example.com/bar@v0", "v0.1.0"), - New: module.MustNewVersion("example.com/baz@v0", "v0.2.0"), - }, - }, - wantNil: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Use an absolute path so the test doesn't fail due to non-OSRootFS - tmpDir := t.TempDir() - lr, err := NewLocalReplacements(module.SourceLoc{ - FS: module.OSDirFS(tmpDir), - Dir: ".", - }, tt.replacements) - if err != nil { - t.Fatalf("NewLocalReplacements() error = %v", err) - } - if (lr == nil) != tt.wantNil { - t.Errorf("NewLocalReplacements() returned nil=%v, want nil=%v", lr == nil, tt.wantNil) - } - }) - } -} - -func TestLocalPathFor(t *testing.T) { - tmpDir := t.TempDir() - replacements := map[string]modfiledata.Replacement{ - "example.com/local@v0": { - Old: module.MustNewVersion("example.com/local@v0", "v0.1.0"), - LocalPath: "./local-dep", - }, - "example.com/remote@v0": { - Old: module.MustNewVersion("example.com/remote@v0", "v0.1.0"), - New: module.MustNewVersion("example.com/other@v0", "v0.2.0"), - }, - } - - lr, err := NewLocalReplacements(module.SourceLoc{ - FS: module.OSDirFS(tmpDir), - Dir: ".", - }, replacements) - if err != nil { - t.Fatalf("NewLocalReplacements() error = %v", err) - } - - tests := []struct { - modulePath string - want string - }{ - {"example.com/local@v0", "./local-dep"}, - {"example.com/remote@v0", ""}, - {"example.com/unknown@v0", ""}, - } - - for _, tt := range tests { - t.Run(tt.modulePath, func(t *testing.T) { - got := lr.LocalPathFor(tt.modulePath) - if got != tt.want { - t.Errorf("LocalPathFor(%q) = %q, want %q", tt.modulePath, got, tt.want) - } - }) - } - - // Test nil receiver - var nilLR *LocalReplacements - if got := nilLR.LocalPathFor("example.com/foo@v0"); got != "" { - t.Errorf("nil.LocalPathFor() = %q, want empty string", got) - } -} - -func TestResolveToAbsPath(t *testing.T) { - // Create a temporary directory structure for testing - tmpDir := t.TempDir() - - replacements := map[string]modfiledata.Replacement{ - "example.com/foo@v0": { - Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), - LocalPath: "./local-dep", - }, - } - - // Test with OSRootFS (using OSDirFS) - lr, err := NewLocalReplacements(module.SourceLoc{ - FS: module.OSDirFS(tmpDir), - Dir: ".", - }, replacements) - if err != nil { - t.Fatalf("NewLocalReplacements() error = %v", err) - } - - absPath, err := lr.ResolveToAbsPath("./local-dep") - if err != nil { - t.Fatalf("ResolveToAbsPath() error = %v", err) - } - - expected := filepath.Join(tmpDir, "local-dep") - if absPath != expected { - t.Errorf("ResolveToAbsPath() = %q, want %q", absPath, expected) - } - - // Test parent directory path - absPath, err = lr.ResolveToAbsPath("../sibling") - if err != nil { - t.Fatalf("ResolveToAbsPath() error = %v", err) - } - - expected = filepath.Clean(filepath.Join(tmpDir, "..", "sibling")) - if absPath != expected { - t.Errorf("ResolveToAbsPath(../sibling) = %q, want %q", absPath, expected) - } - - // Test nil receiver - var nilLR *LocalReplacements - _, err = nilLR.ResolveToAbsPath("./foo") - if err == nil { - t.Error("nil.ResolveToAbsPath() expected error, got nil") - } -} - -func TestFetchSourceLoc(t *testing.T) { - // Create a temporary directory structure - tmpDir := t.TempDir() - localDepDir := filepath.Join(tmpDir, "local-dep") - if err := os.MkdirAll(localDepDir, 0755); err != nil { - t.Fatal(err) - } - - // Create a file (not directory) for error testing - filePath := filepath.Join(tmpDir, "not-a-dir") - if err := os.WriteFile(filePath, []byte("test"), 0644); err != nil { - t.Fatal(err) - } - - replacements := map[string]modfiledata.Replacement{ - "example.com/foo@v0": { - Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), - LocalPath: "./local-dep", - }, - } - - lr, err := NewLocalReplacements(module.SourceLoc{ - FS: module.OSDirFS(tmpDir), - Dir: ".", - }, replacements) - if err != nil { - t.Fatalf("NewLocalReplacements() error = %v", err) - } - - // Test successful fetch - loc, err := lr.FetchSourceLoc("./local-dep") - if err != nil { - t.Fatalf("FetchSourceLoc() error = %v", err) - } - if loc.Dir != "." { - t.Errorf("FetchSourceLoc().Dir = %q, want \".\"", loc.Dir) - } - - // Test missing directory - _, err = lr.FetchSourceLoc("./nonexistent") - if err == nil { - t.Error("FetchSourceLoc(nonexistent) expected error, got nil") - } - - // Test path is file, not directory - _, err = lr.FetchSourceLoc("./not-a-dir") - if err == nil { - t.Error("FetchSourceLoc(file) expected error, got nil") - } -} - -func TestFetchRequirements(t *testing.T) { - // Create a temporary directory structure - tmpDir := t.TempDir() - - // Create local-dep with module.cue - localDepDir := filepath.Join(tmpDir, "local-dep", "cue.mod") - if err := os.MkdirAll(localDepDir, 0755); err != nil { - t.Fatal(err) - } - moduleCue := `module: "example.com/dep@v0" -language: version: "v0.9.0" -deps: { - "example.com/transitive@v0": v: "v0.1.0" -} -` - if err := os.WriteFile(filepath.Join(localDepDir, "module.cue"), []byte(moduleCue), 0644); err != nil { - t.Fatal(err) - } - - // Create local-nodeps without module.cue - localNoDepsDir := filepath.Join(tmpDir, "local-nodeps") - if err := os.MkdirAll(localNoDepsDir, 0755); err != nil { - t.Fatal(err) - } - - // Create local-invalid with invalid module.cue - localInvalidDir := filepath.Join(tmpDir, "local-invalid", "cue.mod") - if err := os.MkdirAll(localInvalidDir, 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(localInvalidDir, "module.cue"), []byte("invalid cue"), 0644); err != nil { - t.Fatal(err) - } - - replacements := map[string]modfiledata.Replacement{ - "example.com/foo@v0": { - Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), - LocalPath: "./local-dep", - }, - } - - lr, err := NewLocalReplacements(module.SourceLoc{ - FS: module.OSDirFS(tmpDir), - Dir: ".", - }, replacements) - if err != nil { - t.Fatalf("NewLocalReplacements() error = %v", err) - } - - // Test with dependencies - deps, err := lr.FetchRequirements("./local-dep") - if err != nil { - t.Fatalf("FetchRequirements() error = %v", err) - } - if len(deps) != 1 { - t.Errorf("FetchRequirements() returned %d deps, want 1", len(deps)) - } - if len(deps) > 0 && deps[0].Path() != "example.com/transitive@v0" { - t.Errorf("deps[0].Path() = %q, want example.com/transitive@v0", deps[0].Path()) - } - - // Test without module.cue (no deps) - deps, err = lr.FetchRequirements("./local-nodeps") - if err != nil { - t.Fatalf("FetchRequirements(nodeps) error = %v", err) - } - if deps != nil { - t.Errorf("FetchRequirements(nodeps) = %v, want nil", deps) - } - - // Test with invalid module.cue - _, err = lr.FetchRequirements("./local-invalid") - if err == nil { - t.Error("FetchRequirements(invalid) expected error, got nil") - } -} - -// TestLocalReplacementWithSelfReference tests behavior when a local replacement -// has a dependency that references the module being replaced. This verifies -// that the system handles such edge cases gracefully without infinite recursion. -// Note: Local replacements are only processed at the main module level, so -// true circular replacement chains are not possible by design. -func TestLocalReplacementWithSelfReference(t *testing.T) { - tmpDir := t.TempDir() - - // Create local-a which has a dependency on example.com/foo (the module being replaced) - localADir := filepath.Join(tmpDir, "local-a", "cue.mod") - if err := os.MkdirAll(localADir, 0755); err != nil { - t.Fatal(err) - } - // This local module depends on example.com/foo - the module it's replacing! - // This tests that we don't get into infinite recursion when resolving deps. - moduleCue := `module: "example.com/local-a@v0" -language: version: "v0.9.0" -deps: { - "example.com/foo@v0": v: "v0.1.0" -} -` - if err := os.WriteFile(filepath.Join(localADir, "module.cue"), []byte(moduleCue), 0644); err != nil { - t.Fatal(err) - } - - replacements := map[string]modfiledata.Replacement{ - "example.com/foo@v0": { - Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), - LocalPath: "./local-a", - }, - } - - lr, err := NewLocalReplacements(module.SourceLoc{ - FS: module.OSDirFS(tmpDir), - Dir: ".", - }, replacements) - if err != nil { - t.Fatalf("NewLocalReplacements() error = %v", err) - } - - // FetchRequirements should succeed and return the deps from local-a, - // even though those deps include the module being replaced. - // The actual resolution of circular deps happens at a higher level. - deps, err := lr.FetchRequirements("./local-a") - if err != nil { - t.Fatalf("FetchRequirements() error = %v", err) - } - if len(deps) != 1 { - t.Errorf("FetchRequirements() returned %d deps, want 1", len(deps)) - } - if len(deps) > 0 && deps[0].Path() != "example.com/foo@v0" { - t.Errorf("deps[0].Path() = %q, want example.com/foo@v0", deps[0].Path()) - } -} - -func TestNewLocalReplacementsPathResolutionError(t *testing.T) { - replacements := map[string]modfiledata.Replacement{ - "example.com/foo@v0": { - Old: module.MustNewVersion("example.com/foo@v0", "v0.1.0"), - LocalPath: "./local-dep", - }, - } - - // Test that NewLocalReplacements returns an error when the filesystem - // doesn't implement OSRootFS and the directory is not absolute. - // Use os.DirFS which doesn't implement OSRootFS. - _, err := NewLocalReplacements(module.SourceLoc{ - FS: os.DirFS("/"), - Dir: "relative-dir", - }, replacements) - if err == nil { - t.Error("NewLocalReplacements() expected error for non-absolute path with non-OSRootFS, got nil") - } - - // Test that it succeeds with an absolute path even without OSRootFS - tmpDir := t.TempDir() - lr, err := NewLocalReplacements(module.SourceLoc{ - FS: os.DirFS("/"), - Dir: tmpDir, // absolute path - }, replacements) - if err != nil { - t.Fatalf("NewLocalReplacements() with absolute path error = %v", err) - } - if lr == nil { - t.Error("NewLocalReplacements() with absolute path returned nil") - } -} diff --git a/mod/modfile/schema.cue b/mod/modfile/schema.cue index 55202dd5d40..1572629aa36 100644 --- a/mod/modfile/schema.cue +++ b/mod/modfile/schema.cue @@ -80,9 +80,9 @@ versions: "v0.9.0-alpha.0": { #Dep: { // v indicates the minimum required version of the module. This can - // be null or empty when the module entry is only present to specify + // be null when the module entry is only present to specify // a local path replacement (version-independent replacement). - v!: #Semver | null | "" + v!: #Semver | null // default indicates this module is used as a default in case more // than one major version is specified for the same module path.