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/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..8bb9a7f0f82 --- /dev/null +++ b/cmd/cue/cmd/testdata/script/modreplace_local.txtar @@ -0,0 +1,60 @@ +# 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"' + +# 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" + +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_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_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_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/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/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 fa4f3ef0104..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()) 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 b6cc3e7605e..e0ffac9cb75 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,59 @@ 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, "../") + + // 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) + } + + 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 +307,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. @@ -249,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 new file mode 100644 index 00000000000..20077ff6229 --- /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 as absolute paths + { + name: "reject UNC path \\\\server", + oldPath: "example.com/foo@v0", + replace: "\\\\server\\share", + wantErr: "absolute path replacement", + }, + { + name: "reject UNC path //server", + oldPath: "example.com/foo@v0", + replace: "//server/share", + wantErr: "absolute 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) + } + }) + } +} diff --git a/internal/mod/modload/localreg.go b/internal/mod/modload/localreg.go new file mode 100644 index 00000000000..7ae329122ff --- /dev/null +++ b/internal/mod/modload/localreg.go @@ -0,0 +1,91 @@ +// 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/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 LocalReplacements to resolve the local path instead of +// delegating to the underlying registry. +type localReplacementRegistry struct { + underlying Registry + localReplace *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 := 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/localreplace.go b/internal/mod/modload/localreplace.go new file mode 100644 index 00000000000..a5e126a0f52 --- /dev/null +++ b/internal/mod/modload/localreplace.go @@ -0,0 +1,155 @@ +// 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 ( + "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/modload/tidy.go b/internal/mod/modload/tidy.go index ddd4b5a6395..aaf791e23e1 100644 --- a/internal/mod/modload/tidy.go +++ b/internal/mod/modload/tidy.go @@ -65,8 +65,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 +88,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 +137,8 @@ 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()) && + maps.Equal(rs0.Replacements(), rs1.Replacements()) } func readModuleFile(fsys fs.FS, modRoot string) (module.Version, *modfile.File, error) { @@ -159,15 +170,36 @@ 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, 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{ + 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 +450,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 +540,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 +661,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 +693,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..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()) - 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()) + 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()) + rs = modrequirements.NewRequirements(mf.QualifiedModule(), wrappedReg, 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/modrequirements/requirements.go b/internal/mod/modrequirements/requirements.go index bf0a1d00c90..c4f53170a29 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,20 @@ 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. + // 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 module + 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..1572629aa36 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 if the version is unknown and the module entry is only - // present to be replaced. + // be null 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 @@ -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.