diff --git a/README.md b/README.md index f8dd66b..8ff2225 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,10 @@ cmd, _ = translator.BuildCommand("bundler", "add", managers.CommandInput{ | yarn | npm | yarn.lock | | bun | npm | bun.lock | | deno | deno | deno.lock | -| bundler | gem | Gemfile.lock | -| gem | gem | - | +| bundler | rubygems | Gemfile.lock | +| gem | rubygems | - | | cargo | cargo | Cargo.lock | -| gomod | go | go.sum | +| gomod | go | - | | pip | pypi | requirements.txt | | uv | pypi | uv.lock | | poetry | pypi | poetry.lock | @@ -65,7 +65,7 @@ cmd, _ = translator.BuildCommand("bundler", "add", managers.CommandInput{ | pub | pub | pubspec.lock | | cocoapods | cocoapods | Podfile.lock | | swift | swift | Package.resolved | -| nuget | nuget | packages.lock.json | +| nuget | nuget | packages.lock.json, project.assets.json | | maven | maven | - | | gradle | maven | gradle.lockfile | | sbt | maven | - | @@ -77,7 +77,7 @@ cmd, _ = translator.BuildCommand("bundler", "add", managers.CommandInput{ | shards | shards | shard.lock | | cpanm | cpan | cpanfile.snapshot | | lein | clojars | - | -| vcpkg | vcpkg | vcpkg.json | +| vcpkg | vcpkg | - | | conan | conan | conan.lock | | renv | cran | renv.lock | | helm | helm | Chart.lock | @@ -85,6 +85,8 @@ cmd, _ = translator.BuildCommand("bundler", "add", managers.CommandInput{ Most managers support: install, add, remove, list, outdated, update, resolve. Some also support init, vendor and path. Some managers (maven, gradle, sbt, lein) have limited CLI support for add/remove operations. +Detection-only definitions are available for Julia Pkg, Carthage, Elm, Dub, Haxelib and Nix. These definitions identify project files but do not define commands. + ## Installation ```bash @@ -132,6 +134,32 @@ func main() { } ``` +### Detecting managers from files + +`DetectFS` and `DetectAllFS` accept any `fs.FS`, including an on-disk directory, a Git tree or an in-memory test filesystem. Detection does not execute package manager commands. + +```go +detector := managers.NewDetector(translator, runner) +for _, def := range defs { + detector.Register(def) +} + +detections, err := detector.DetectAllFS(repoFS, ".", managers.FileDetectOptions{ + OnConflict: managers.ConflictUseFirst, +}) +``` + +`GitPkgsFileDetectionPolicy` preserves the filenames, manager priority and result order used by the git-pkgs CLI. Leave `Policy` unset to detect every registered definition. + +```go +detections, err := detector.DetectAllFS(repoFS, ".", managers.FileDetectOptions{ + OnConflict: managers.ConflictUseFirst, + Policy: managers.GitPkgsFileDetectionPolicy(), +}) +``` + +History scanners can call `AffectedDirectories` for a changed path, then rerun detection only in the returned project directories. + ### Command chaining Some operations require multiple commands. Use `BuildCommands` to get all of them: diff --git a/definitions/bun.yaml b/definitions/bun.yaml index 76c47ea..eec64b6 100644 --- a/definitions/bun.yaml +++ b/definitions/bun.yaml @@ -15,6 +15,7 @@ detection: - bun.lockb manifests: - package.json + ecosystem_order: 10 priority: 25 # Higher than npm/yarn/pnpm when bun lockfile present commands: diff --git a/definitions/bundler.yaml b/definitions/bundler.yaml index 3290e97..ddf1d3a 100644 --- a/definitions/bundler.yaml +++ b/definitions/bundler.yaml @@ -1,5 +1,5 @@ name: bundler -ecosystem: gem +ecosystem: rubygems binary: bundle version: ">=2.0.0" status: current @@ -11,6 +11,8 @@ detection: - Gemfile.lock manifests: - Gemfile + default: true + ecosystem_order: 20 priority: 10 version_detection: diff --git a/definitions/cabal.yaml b/definitions/cabal.yaml index b7e5da1..ed7cc99 100644 --- a/definitions/cabal.yaml +++ b/definitions/cabal.yaml @@ -9,7 +9,9 @@ detection: manifests: - "*.cabal" - cabal.project - priority: 10 + default: true + ecosystem_order: 140 + priority: 20 version_detection: command: [--version] diff --git a/definitions/cargo.yaml b/definitions/cargo.yaml index 45920d2..c20a0e7 100644 --- a/definitions/cargo.yaml +++ b/definitions/cargo.yaml @@ -11,6 +11,8 @@ detection: - Cargo.lock manifests: - Cargo.toml + default: true + ecosystem_order: 30 priority: 10 version_detection: diff --git a/definitions/carthage.yaml b/definitions/carthage.yaml new file mode 100644 index 0000000..f699068 --- /dev/null +++ b/definitions/carthage.yaml @@ -0,0 +1,17 @@ +name: carthage +ecosystem: carthage +binary: carthage +status: detection-only + +detection: + lockfiles: + - Cartfile.resolved + manifests: + - Cartfile + - Cartfile.private + default: true + ecosystem_order: 190 + priority: 10 + +commands: {} +capabilities: [] diff --git a/definitions/cocoapods.yaml b/definitions/cocoapods.yaml index f0a1839..2e6ec4e 100644 --- a/definitions/cocoapods.yaml +++ b/definitions/cocoapods.yaml @@ -14,6 +14,8 @@ detection: - Podfile.lock manifests: - Podfile + default: true + ecosystem_order: 90 priority: 10 commands: diff --git a/definitions/composer.yaml b/definitions/composer.yaml index 342b5ab..645dada 100644 --- a/definitions/composer.yaml +++ b/definitions/composer.yaml @@ -14,6 +14,8 @@ detection: - composer.lock manifests: - composer.json + default: true + ecosystem_order: 60 priority: 10 commands: diff --git a/definitions/conan.yaml b/definitions/conan.yaml index 7cb0339..3ca95e0 100644 --- a/definitions/conan.yaml +++ b/definitions/conan.yaml @@ -9,6 +9,8 @@ detection: manifests: - conanfile.txt - conanfile.py + default: true + ecosystem_order: 170 priority: 10 version_detection: diff --git a/definitions/conda.yaml b/definitions/conda.yaml index 940665d..ae66c5e 100644 --- a/definitions/conda.yaml +++ b/definitions/conda.yaml @@ -9,6 +9,8 @@ detection: manifests: - environment.yml - environment.yaml + default: true + ecosystem_order: 270 priority: 10 version_detection: diff --git a/definitions/cpanm.yaml b/definitions/cpanm.yaml index 7243e99..8221826 100644 --- a/definitions/cpanm.yaml +++ b/definitions/cpanm.yaml @@ -10,6 +10,8 @@ detection: - cpanfile - Makefile.PL - Build.PL + default: true + ecosystem_order: 200 priority: 10 version_detection: diff --git a/definitions/deno.yaml b/definitions/deno.yaml index d015034..46a5214 100644 --- a/definitions/deno.yaml +++ b/definitions/deno.yaml @@ -9,6 +9,8 @@ detection: manifests: - deno.json - deno.jsonc + default: true + ecosystem_order: 130 priority: 10 version_detection: diff --git a/definitions/dub.yaml b/definitions/dub.yaml new file mode 100644 index 0000000..c1c5258 --- /dev/null +++ b/definitions/dub.yaml @@ -0,0 +1,15 @@ +name: dub +ecosystem: dub +binary: dub +status: detection-only + +detection: + manifests: + - dub.json + - dub.sdl + default: true + ecosystem_order: 240 + priority: 10 + +commands: {} +capabilities: [] diff --git a/definitions/elm.yaml b/definitions/elm.yaml new file mode 100644 index 0000000..a838288 --- /dev/null +++ b/definitions/elm.yaml @@ -0,0 +1,14 @@ +name: elm +ecosystem: elm +binary: elm +status: detection-only + +detection: + manifests: + - elm.json + default: true + ecosystem_order: 230 + priority: 10 + +commands: {} +capabilities: [] diff --git a/definitions/gem.yaml b/definitions/gem.yaml index 452d5ba..1532b35 100644 --- a/definitions/gem.yaml +++ b/definitions/gem.yaml @@ -7,6 +7,7 @@ detection: lockfiles: [] manifests: - "*.gemspec" + ecosystem_order: 20 priority: 5 version_detection: diff --git a/definitions/gomod.yaml b/definitions/gomod.yaml index 72e9e72..ebdb4c1 100644 --- a/definitions/gomod.yaml +++ b/definitions/gomod.yaml @@ -1,5 +1,5 @@ name: gomod -ecosystem: golang +ecosystem: go binary: go version: ">=1.18" status: current @@ -7,10 +7,10 @@ min_tested: "1.18" max_tested: "1.24.0" detection: - lockfiles: - - go.sum manifests: - go.mod + default: true + ecosystem_order: 40 priority: 10 version_detection: diff --git a/definitions/gradle.yaml b/definitions/gradle.yaml index db3831f..d20c734 100644 --- a/definitions/gradle.yaml +++ b/definitions/gradle.yaml @@ -9,6 +9,7 @@ detection: manifests: - build.gradle - build.gradle.kts + ecosystem_order: 100 priority: 10 version_detection: diff --git a/definitions/haxelib.yaml b/definitions/haxelib.yaml new file mode 100644 index 0000000..009ef90 --- /dev/null +++ b/definitions/haxelib.yaml @@ -0,0 +1,14 @@ +name: haxelib +ecosystem: haxelib +binary: haxelib +status: detection-only + +detection: + manifests: + - haxelib.json + default: true + ecosystem_order: 250 + priority: 10 + +commands: {} +capabilities: [] diff --git a/definitions/lein.yaml b/definitions/lein.yaml index 89898ff..868b288 100644 --- a/definitions/lein.yaml +++ b/definitions/lein.yaml @@ -7,6 +7,8 @@ detection: lockfiles: [] manifests: - project.clj + default: true + ecosystem_order: 220 priority: 10 version_detection: diff --git a/definitions/maven.yaml b/definitions/maven.yaml index bd7d5ba..fb07607 100644 --- a/definitions/maven.yaml +++ b/definitions/maven.yaml @@ -7,6 +7,8 @@ detection: lockfiles: [] manifests: - pom.xml + default: true + ecosystem_order: 100 priority: 10 version_detection: diff --git a/definitions/mix.yaml b/definitions/mix.yaml index 734358c..db4d913 100644 --- a/definitions/mix.yaml +++ b/definitions/mix.yaml @@ -14,6 +14,8 @@ detection: - mix.lock manifests: - mix.exs + default: true + ecosystem_order: 70 priority: 10 commands: diff --git a/definitions/nix.yaml b/definitions/nix.yaml new file mode 100644 index 0000000..056e6c2 --- /dev/null +++ b/definitions/nix.yaml @@ -0,0 +1,16 @@ +name: nix +ecosystem: nix +binary: nix +status: detection-only + +detection: + lockfiles: + - flake.lock + manifests: + - flake.nix + default: true + ecosystem_order: 260 + priority: 10 + +commands: {} +capabilities: [] diff --git a/definitions/npm.yaml b/definitions/npm.yaml index 3364386..e5f26bf 100644 --- a/definitions/npm.yaml +++ b/definitions/npm.yaml @@ -12,6 +12,8 @@ detection: - npm-shrinkwrap.json manifests: - package.json + default: true + ecosystem_order: 10 priority: 10 version_detection: diff --git a/definitions/nuget.yaml b/definitions/nuget.yaml index adb4945..ffd401c 100644 --- a/definitions/nuget.yaml +++ b/definitions/nuget.yaml @@ -6,10 +6,14 @@ version: ">=6.0.0" detection: lockfiles: - packages.lock.json + - project.assets.json manifests: - "*.csproj" - "*.fsproj" - "*.vbproj" + - packages.config + default: true + ecosystem_order: 110 priority: 10 version_detection: diff --git a/definitions/pip.yaml b/definitions/pip.yaml index f5533b5..838e88b 100644 --- a/definitions/pip.yaml +++ b/definitions/pip.yaml @@ -10,6 +10,7 @@ detection: - requirements.in - setup.py - setup.cfg + ecosystem_order: 50 priority: 5 version_detection: diff --git a/definitions/pkg.yaml b/definitions/pkg.yaml new file mode 100644 index 0000000..2bd9b1d --- /dev/null +++ b/definitions/pkg.yaml @@ -0,0 +1,16 @@ +name: pkg +ecosystem: julia +binary: julia +status: detection-only + +detection: + lockfiles: + - Manifest.toml + manifests: + - Project.toml + default: true + ecosystem_order: 160 + priority: 10 + +commands: {} +capabilities: [] diff --git a/definitions/pnpm.yaml b/definitions/pnpm.yaml index c98ca74..8f6e054 100644 --- a/definitions/pnpm.yaml +++ b/definitions/pnpm.yaml @@ -11,6 +11,7 @@ detection: - pnpm-lock.yaml manifests: - package.json + ecosystem_order: 10 priority: 20 # higher than npm when pnpm-lock.yaml present version_detection: diff --git a/definitions/poetry.yaml b/definitions/poetry.yaml index f372168..069d965 100644 --- a/definitions/poetry.yaml +++ b/definitions/poetry.yaml @@ -16,8 +16,9 @@ detection: - pyproject.toml file_checks: - file: pyproject.toml - contains: "[tool.poetry]" - priority: 20 # Higher than uv for poetry-specific projects + match: '\[tool\.poetry\]' + ecosystem_order: 50 + priority: 20 commands: init: diff --git a/definitions/pub.yaml b/definitions/pub.yaml index d0b9c10..270eece 100644 --- a/definitions/pub.yaml +++ b/definitions/pub.yaml @@ -14,6 +14,8 @@ detection: - pubspec.lock manifests: - pubspec.yaml + default: true + ecosystem_order: 80 priority: 10 commands: diff --git a/definitions/renv.yaml b/definitions/renv.yaml index 312995f..80eae5b 100644 --- a/definitions/renv.yaml +++ b/definitions/renv.yaml @@ -18,6 +18,8 @@ detection: file_checks: - file: .Rprofile match: 'renv/activate' + default: true + ecosystem_order: 210 priority: 10 version_detection: diff --git a/definitions/schema.go b/definitions/schema.go index 9f3d252..00fd5ad 100644 --- a/definitions/schema.go +++ b/definitions/schema.go @@ -15,10 +15,12 @@ type Definition struct { } type Detection struct { - Lockfiles []string `yaml:"lockfiles,omitempty"` - Manifests []string `yaml:"manifests,omitempty"` - Priority int `yaml:"priority"` - FileChecks []FileCheck `yaml:"file_checks,omitempty"` + Lockfiles []string `yaml:"lockfiles,omitempty"` + Manifests []string `yaml:"manifests,omitempty"` + Priority int `yaml:"priority"` + Default bool `yaml:"default,omitempty"` + EcosystemOrder int `yaml:"ecosystem_order,omitempty"` + FileChecks []FileCheck `yaml:"file_checks,omitempty"` } type FileCheck struct { diff --git a/definitions/shards.yaml b/definitions/shards.yaml index edb8732..3ac2696 100644 --- a/definitions/shards.yaml +++ b/definitions/shards.yaml @@ -8,6 +8,8 @@ detection: - shard.lock manifests: - shard.yml + default: true + ecosystem_order: 150 priority: 10 version_detection: diff --git a/definitions/stack.yaml b/definitions/stack.yaml index ddd540b..a7455a1 100644 --- a/definitions/stack.yaml +++ b/definitions/stack.yaml @@ -9,6 +9,7 @@ detection: manifests: - stack.yaml - package.yaml + ecosystem_order: 140 priority: 15 version_detection: diff --git a/definitions/swift.yaml b/definitions/swift.yaml index 6a7b452..bcf3d0c 100644 --- a/definitions/swift.yaml +++ b/definitions/swift.yaml @@ -8,6 +8,8 @@ detection: - Package.resolved manifests: - Package.swift + default: true + ecosystem_order: 120 priority: 10 version_detection: diff --git a/definitions/uv.yaml b/definitions/uv.yaml index bd16a2e..c65c7d0 100644 --- a/definitions/uv.yaml +++ b/definitions/uv.yaml @@ -14,7 +14,9 @@ detection: file_checks: - file: pyproject.toml match: '\[tool\.uv\]' - priority: 20 # higher than pip/poetry when uv.lock present + default: true + ecosystem_order: 50 + priority: 30 # higher than pip/poetry when uv.lock present version_detection: command: [--version] diff --git a/definitions/vcpkg.yaml b/definitions/vcpkg.yaml index f9d05f4..ddb3578 100644 --- a/definitions/vcpkg.yaml +++ b/definitions/vcpkg.yaml @@ -4,10 +4,10 @@ binary: vcpkg version: ">=2021.05.12" detection: - lockfiles: - - vcpkg.json manifests: - vcpkg.json + default: true + ecosystem_order: 180 priority: 10 version_detection: diff --git a/definitions/yarn.yaml b/definitions/yarn.yaml index 213769d..32d5980 100644 --- a/definitions/yarn.yaml +++ b/definitions/yarn.yaml @@ -17,6 +17,7 @@ detection: # Yarn berry uses .yarnrc.yml, classic uses .yarnrc - file: .yarnrc.yml exists: false + ecosystem_order: 10 priority: 15 # higher than npm when yarn.lock present version_detection: diff --git a/detection_policy.go b/detection_policy.go new file mode 100644 index 0000000..9c80cbf --- /dev/null +++ b/detection_policy.go @@ -0,0 +1,116 @@ +package managers + +const ( + npmManagerName = "npm" + nugetManagerName = "nuget" +) + +// FileDetectionPolicy defines an ordered set of file-based detection rules. +type FileDetectionPolicy struct { + Ecosystems []EcosystemDetectionPolicy +} + +// EcosystemDetectionPolicy selects one manager for an ecosystem. +type EcosystemDetectionPolicy struct { + Ecosystem string + DefaultManager string + Manifests []string + Lockfiles []LockfileDetectionPolicy +} + +// LockfileDetectionPolicy associates a lockfile with a manager. +type LockfileDetectionPolicy struct { + File string + Manager string +} + +// GitPkgsFileDetectionPolicy returns the file rules used by the git-pkgs CLI. +func GitPkgsFileDetectionPolicy() *FileDetectionPolicy { + return &FileDetectionPolicy{Ecosystems: []EcosystemDetectionPolicy{ + { + Ecosystem: npmManagerName, + DefaultManager: npmManagerName, + Manifests: []string{"package.json"}, + Lockfiles: []LockfileDetectionPolicy{ + {File: "bun.lock", Manager: "bun"}, + {File: "pnpm-lock.yaml", Manager: "pnpm"}, + {File: "yarn.lock", Manager: "yarn"}, + {File: "package-lock.json", Manager: npmManagerName}, + }, + }, + fileDetectionPolicy("rubygems", "bundler", "Gemfile", "Gemfile.lock"), + fileDetectionPolicy("cargo", "cargo", "Cargo.toml", "Cargo.lock"), + fileDetectionPolicy("go", "gomod", "go.mod", ""), + { + Ecosystem: "pypi", + DefaultManager: "uv", + Manifests: []string{"pyproject.toml"}, + Lockfiles: []LockfileDetectionPolicy{ + {File: "uv.lock", Manager: "uv"}, + {File: "poetry.lock", Manager: "poetry"}, + }, + }, + fileDetectionPolicy("packagist", "composer", "composer.json", "composer.lock"), + fileDetectionPolicy("hex", "mix", "mix.exs", "mix.lock"), + fileDetectionPolicy("pub", "pub", "pubspec.yaml", "pubspec.lock"), + fileDetectionPolicy("cocoapods", "cocoapods", "Podfile", "Podfile.lock"), + { + Ecosystem: "maven", + DefaultManager: "maven", + Manifests: []string{"pom.xml"}, + Lockfiles: []LockfileDetectionPolicy{{File: "gradle.lockfile", Manager: "gradle"}}, + }, + { + Ecosystem: nugetManagerName, + DefaultManager: nugetManagerName, + Manifests: []string{"packages.config"}, + Lockfiles: []LockfileDetectionPolicy{ + {File: "packages.lock.json", Manager: nugetManagerName}, + {File: "project.assets.json", Manager: nugetManagerName}, + }, + }, + fileDetectionPolicy("swift", "swift", "Package.swift", "Package.resolved"), + fileDetectionPolicy("deno", "deno", "deno.json", "deno.lock"), + { + Ecosystem: "hackage", + DefaultManager: "cabal", + Manifests: []string{"*.cabal"}, + Lockfiles: []LockfileDetectionPolicy{ + {File: "cabal.project.freeze", Manager: "cabal"}, + {File: "stack.yaml.lock", Manager: "stack"}, + }, + }, + fileDetectionPolicy("crystal", "shards", "shard.yml", "shard.lock"), + fileDetectionPolicy("julia", "pkg", "Project.toml", "Manifest.toml"), + fileDetectionPolicy("conan", "conan", "conanfile.txt", "conan.lock"), + fileDetectionPolicy("vcpkg", "vcpkg", "vcpkg.json", ""), + fileDetectionPolicy("carthage", "carthage", "Cartfile", "Cartfile.resolved"), + fileDetectionPolicy("cpan", "cpanm", "cpanfile", "cpanfile.snapshot"), + fileDetectionPolicy("cran", "renv", "DESCRIPTION", "renv.lock"), + fileDetectionPolicy("clojars", "lein", "project.clj", ""), + fileDetectionPolicy("elm", "elm", "elm.json", ""), + fileDetectionPolicy("dub", "dub", "dub.json", ""), + fileDetectionPolicy("haxelib", "haxelib", "haxelib.json", ""), + fileDetectionPolicy("nix", "nix", "flake.nix", "flake.lock"), + fileDetectionPolicy("conda", "conda", "environment.yml", ""), + }} +} + +func fileDetectionPolicy( + ecosystem string, + manager string, + manifest string, + lockfile string, +) EcosystemDetectionPolicy { + policy := EcosystemDetectionPolicy{ + Ecosystem: ecosystem, + DefaultManager: manager, + } + if manifest != "" { + policy.Manifests = []string{manifest} + } + if lockfile != "" { + policy.Lockfiles = []LockfileDetectionPolicy{{File: lockfile, Manager: manager}} + } + return policy +} diff --git a/detector.go b/detector.go index c6dd753..93396fa 100644 --- a/detector.go +++ b/detector.go @@ -1,8 +1,11 @@ package managers import ( + "errors" + "io/fs" "os" "os/exec" + "path" "regexp" "sort" "strings" @@ -17,6 +20,22 @@ type DetectOptions struct { Manager string } +// FileDetectOptions controls package manager detection without command execution. +type FileDetectOptions struct { + OnConflict ConflictBehavior + Manager string + Policy *FileDetectionPolicy +} + +// FileDetection describes the files that identify a package manager. +type FileDetection struct { + Manager string + Ecosystem string + Directory string + Manifest string + Lockfile string +} + type ConflictBehavior int const ( @@ -45,7 +64,7 @@ func (d *Detector) Register(def *definitions.Definition) { } func (d *Detector) sortDefinitions() { - sort.Slice(d.definitions, func(i, j int) bool { + sort.SliceStable(d.definitions, func(i, j int) bool { return d.definitions[i].Detection.Priority > d.definitions[j].Detection.Priority }) } @@ -55,49 +74,597 @@ func (d *Detector) Detect(dir string, opts DetectOptions) (Manager, error) { //n return d.detectExplicit(dir, opts.Manager, opts.RequireCLI) } - files, err := os.ReadDir(dir) + match, def, err := d.detectFS(os.DirFS(dir), ".", dir, FileDetectOptions{ + OnConflict: opts.OnConflict, + }) if err != nil { return nil, err } - fileSet := make(map[string]bool) - for _, f := range files { - fileSet[f.Name()] = true + files := []string{match.Lockfile} + if match.Lockfile == "" { + files = []string{match.Manifest} + } + + return d.buildManager(def, dir, files, opts.RequireCLI) +} + +// DetectFS detects a package manager from files in dir. It does not look for or +// execute the package manager CLI. +func (d *Detector) DetectFS(fsys fs.FS, dir string, opts FileDetectOptions) (FileDetection, error) { + if opts.Policy != nil { + detections, err := d.detectAllPolicyFS(fsys, dir, opts) + if err != nil { + return FileDetection{}, err + } + if len(detections) == 0 { + return FileDetection{}, ErrNoManifest{Dir: dir} + } + return detections[0], nil } + match, _, err := d.detectFS(fsys, dir, dir, opts) + return match, err +} - var lockfileMatches []*definitions.Definition - var lockfileNames []string +// DetectAllFS detects one package manager per ecosystem from files in dir. It +// does not look for or execute package manager CLIs. +func (d *Detector) DetectAllFS(fsys fs.FS, dir string, opts FileDetectOptions) ([]FileDetection, error) { + if opts.Policy != nil { + return d.detectAllPolicyFS(fsys, dir, opts) + } + matcher, err := newFileMatcher(fsys, dir) + if err != nil { + return nil, err + } + + if opts.Manager != "" { + match, _, err := d.detectExplicitFS(matcher, dir, opts.Manager) + if err != nil { + return nil, err + } + return []FileDetection{match}, nil + } + + matches, err := d.matchDefinitions(matcher) + if err != nil { + return nil, err + } + + byEcosystem := make(map[string][]matchedDefinition) + for _, match := range matches { + if len(match.matches.manifests) == 0 && len(match.matches.lockfiles) == 0 { + continue + } + byEcosystem[match.def.Ecosystem] = append(byEcosystem[match.def.Ecosystem], match) + } + + detections := make([]FileDetection, 0, len(byEcosystem)) + for _, ecosystemMatches := range byEcosystem { + detection, err := selectEcosystemDetection(dir, ecosystemMatches, opts.OnConflict) + if err != nil { + return nil, err + } + detections = append(detections, detection) + } + + sort.Slice(detections, func(i, j int) bool { + left, leftConfigured := d.ecosystemOrder(detections[i].Ecosystem) + right, rightConfigured := d.ecosystemOrder(detections[j].Ecosystem) + if leftConfigured != rightConfigured { + return leftConfigured + } + if leftConfigured && left != right { + return left < right + } + return detections[i].Ecosystem < detections[j].Ecosystem + }) + return detections, nil +} + +func (d *Detector) detectAllPolicyFS( + fsys fs.FS, + dir string, + opts FileDetectOptions, +) ([]FileDetection, error) { + matcher, err := newFileMatcher(fsys, dir) + if err != nil { + return nil, err + } + + if opts.Manager != "" { + match, _, err := d.detectExplicitFS(matcher, dir, opts.Manager) + if err != nil { + return nil, err + } + return []FileDetection{match}, nil + } + + var detections []FileDetection + for _, ecosystem := range opts.Policy.Ecosystems { + manifests, err := matcher.matchPatterns(ecosystem.Manifests) + if err != nil { + return nil, err + } + + type lockfileMatch struct { + manager string + lockfile string + } + + var lockfiles []lockfileMatch + for _, rule := range ecosystem.Lockfiles { + matches, err := matcher.matchPatterns([]string{rule.File}) + if err != nil { + return nil, err + } + for _, name := range matches { + lockfiles = append(lockfiles, lockfileMatch{manager: rule.Manager, lockfile: name}) + } + } + + if len(lockfiles) > 1 && opts.OnConflict == ConflictError { + names := make([]string, 0, len(lockfiles)) + for _, match := range lockfiles { + names = append(names, match.lockfile) + } + return nil, ErrConflictingLockfiles{Dir: dir, Lockfiles: names} + } + if len(lockfiles) > 0 { + detections = append(detections, FileDetection{ + Manager: lockfiles[0].manager, + Ecosystem: ecosystem.Ecosystem, + Directory: dir, + Manifest: first(manifests), + Lockfile: lockfiles[0].lockfile, + }) + continue + } + + if len(manifests) > 0 { + detections = append(detections, FileDetection{ + Manager: ecosystem.DefaultManager, + Ecosystem: ecosystem.Ecosystem, + Directory: dir, + Manifest: manifests[0], + }) + } + } + + return detections, nil +} + +// Ecosystem returns the ecosystem registered for managerName. +func (d *Detector) Ecosystem(managerName string) (string, bool) { + for _, def := range d.definitions { + if def.Name == managerName { + return def.Ecosystem, true + } + } + return "", false +} + +func (d *Detector) ecosystemOrder(ecosystem string) (int, bool) { + order := 0 + configured := false + for _, def := range d.definitions { + if def.Ecosystem != ecosystem || def.Detection.EcosystemOrder <= 0 { + continue + } + if !configured || def.Detection.EcosystemOrder < order { + order = def.Detection.EcosystemOrder + configured = true + } + } + return order, configured +} + +// AffectedDirectories returns project directories whose detection result may +// change when filePath changes. +func (d *Detector) AffectedDirectories(filePath string) ([]string, error) { + directories := make(map[string]bool) for _, def := range d.definitions { - for _, lockfile := range def.Detection.Lockfiles { - if fileSet[lockfile] { - lockfileMatches = append(lockfileMatches, def) - lockfileNames = append(lockfileNames, lockfile) + patterns := append([]string(nil), def.Detection.Manifests...) + patterns = append(patterns, def.Detection.Lockfiles...) + for _, check := range def.Detection.FileChecks { + patterns = append(patterns, check.File) + } + for _, pattern := range patterns { + directory, matched, err := affectedDirectory(filePath, pattern) + if err != nil { + return nil, err + } + if matched { + directories[directory] = true + } + } + } + + result := make([]string, 0, len(directories)) + for directory := range directories { + result = append(result, directory) + } + if len(result) == 0 { + return nil, nil + } + sort.Strings(result) + return result, nil +} + +func affectedDirectory(filePath, pattern string) (string, bool, error) { + filePath = path.Clean(filePath) + for offset := 0; offset < len(filePath); { + suffix := filePath[offset:] + matched, err := path.Match(pattern, suffix) + if err != nil { + return "", false, err + } + if matched { + directory := strings.TrimSuffix(filePath[:offset], "/") + if directory == "" { + directory = "." } + return directory, true, nil + } + + next := strings.IndexByte(suffix, '/') + if next < 0 { + break + } + offset += next + 1 + } + return "", false, nil +} + +type matchedDefinition struct { + def *definitions.Definition + matches definitionMatch +} + +func (d *Detector) detectFS( + fsys fs.FS, + dir string, + reportedDir string, + opts FileDetectOptions, +) (FileDetection, *definitions.Definition, error) { + matcher, err := newFileMatcher(fsys, dir) + if err != nil { + return FileDetection{}, nil, err + } + + if opts.Manager != "" { + return d.detectExplicitFS(matcher, reportedDir, opts.Manager) + } + + type lockfileMatch struct { + def *definitions.Definition + lockfile string + manifest string + } + + matchedDefinitions, err := d.matchDefinitions(matcher) + if err != nil { + return FileDetection{}, nil, err + } + + var lockfileMatches []lockfileMatch + for _, matched := range matchedDefinitions { + def := matched.def + matches := matched.matches + for _, lockfile := range matches.lockfiles { + lockfileMatches = append(lockfileMatches, lockfileMatch{ + def: def, + lockfile: lockfile, + manifest: first(matches.manifests), + }) } } if len(lockfileMatches) > 1 && opts.OnConflict == ConflictError { - return nil, ErrConflictingLockfiles{ - Dir: dir, - Lockfiles: lockfileNames, + lockfiles := make([]string, 0, len(lockfileMatches)) + for _, match := range lockfileMatches { + lockfiles = append(lockfiles, match.lockfile) + } + return FileDetection{}, nil, ErrConflictingLockfiles{ + Dir: reportedDir, + Lockfiles: lockfiles, + } + } + + if len(lockfileMatches) > 0 { + match := lockfileMatches[0] + return newFileDetection(match.def, reportedDir, match.manifest, match.lockfile), match.def, nil + } + + if matched, ok := selectManifestDefinition(matchedDefinitions); ok { + return newFileDetection( + matched.def, + reportedDir, + matched.matches.manifests[0], + "", + ), matched.def, nil + } + + return FileDetection{}, nil, ErrNoManifest{Dir: reportedDir} +} + +func (d *Detector) matchDefinitions(matcher *fileMatcher) ([]matchedDefinition, error) { + var matched []matchedDefinition + for _, def := range d.definitions { + matches, err := matcher.matchDefinition(def) + if err != nil { + return nil, err + } + if len(matches.manifests) > 0 || len(matches.lockfiles) > 0 { + matched = append(matched, matchedDefinition{def: def, matches: matches}) } } + return matched, nil +} - if len(lockfileMatches) >= 1 { - def := lockfileMatches[0] - return d.buildManager(def, dir, lockfileNames[:1], opts.RequireCLI) +func selectEcosystemDetection( + dir string, + matches []matchedDefinition, + onConflict ConflictBehavior, +) (FileDetection, error) { + type lockfileMatch struct { + definition matchedDefinition + lockfile string + } + + var lockfiles []lockfileMatch + for _, match := range matches { + for _, lockfile := range match.matches.lockfiles { + lockfiles = append(lockfiles, lockfileMatch{definition: match, lockfile: lockfile}) + } } + if len(lockfiles) > 1 && onConflict == ConflictError { + names := make([]string, 0, len(lockfiles)) + for _, match := range lockfiles { + names = append(names, match.lockfile) + } + return FileDetection{}, ErrConflictingLockfiles{Dir: dir, Lockfiles: names} + } + if len(lockfiles) > 0 { + match := lockfiles[0] + return newFileDetection( + match.definition.def, + dir, + first(match.definition.matches.manifests), + match.lockfile, + ), nil + } + + if match, ok := selectManifestDefinition(matches); ok { + return newFileDetection(match.def, dir, match.matches.manifests[0], ""), nil + } + + return FileDetection{}, ErrNoManifest{Dir: dir} +} + +func selectManifestDefinition(matches []matchedDefinition) (matchedDefinition, bool) { + for _, match := range matches { + if match.matches.checksMatch && hasPositiveFileCheck(match.def) && len(match.matches.manifests) > 0 { + return match, true + } + } + + for _, match := range matches { + if match.def.Detection.Default && len(match.matches.manifests) > 0 { + return match, true + } + } + + for i := len(matches) - 1; i >= 0; i-- { + match := matches[i] + if match.matches.checksMatch && len(match.matches.manifests) > 0 { + return match, true + } + } + + return matchedDefinition{}, false +} + +func hasPositiveFileCheck(def *definitions.Definition) bool { + for _, check := range def.Detection.FileChecks { + if check.Match != "" || check.Exists { + return true + } + } + return false +} + +func (d *Detector) detectExplicitFS( + matcher *fileMatcher, + reportedDir string, + managerName string, +) (FileDetection, *definitions.Definition, error) { for _, def := range d.definitions { - for _, manifest := range def.Detection.Manifests { - if fileSet[manifest] { - return d.buildManager(def, dir, []string{manifest}, opts.RequireCLI) + if def.Name != managerName { + continue + } + + matches, err := matcher.matchDefinition(def) + if err != nil { + return FileDetection{}, nil, err + } + return newFileDetection( + def, + reportedDir, + first(matches.manifests), + first(matches.lockfiles), + ), def, nil + } + + return FileDetection{}, nil, ErrNoManifest{Dir: reportedDir} +} + +type definitionMatch struct { + checksMatch bool + manifests []string + lockfiles []string +} + +type fileMatcher struct { + fsys fs.FS + dir string + files []string +} + +func newFileMatcher(fsys fs.FS, dir string) (*fileMatcher, error) { + entries, err := fs.ReadDir(fsys, dir) + if err != nil { + return nil, err + } + + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, entry.Name()) + } + } + + return &fileMatcher{fsys: fsys, dir: dir, files: files}, nil +} + +func (m *fileMatcher) matchDefinition(def *definitions.Definition) (definitionMatch, error) { + checksMatch, err := m.matchFileChecks(def.Detection.FileChecks) + if err != nil { + return definitionMatch{}, err + } + + manifests, err := m.matchPatterns(def.Detection.Manifests) + if err != nil { + return definitionMatch{}, err + } + lockfiles, err := m.matchPatterns(def.Detection.Lockfiles) + if err != nil { + return definitionMatch{}, err + } + + return definitionMatch{ + checksMatch: checksMatch, + manifests: manifests, + lockfiles: lockfiles, + }, nil +} + +func (m *fileMatcher) matchFileChecks(checks []definitions.FileCheck) (bool, error) { + for _, check := range checks { + matches, err := m.matchPatterns([]string{check.File}) + if err != nil { + return false, err + } + + if check.Match != "" { + matched, err := m.matchFileContents(matches, check.Match) + if err != nil || !matched { + return matched, err } + continue + } + + if check.Exists != (len(matches) > 0) { + return false, nil } } - return nil, ErrNoManifest{Dir: dir} + return true, nil +} + +func (m *fileMatcher) matchFileContents(files []string, pattern string) (bool, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return false, err + } + + for _, name := range files { + contents, err := fs.ReadFile(m.fsys, joinFSPath(m.dir, name)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + return false, err + } + if re.Match(contents) { + return true, nil + } + } + + return false, nil +} + +func (m *fileMatcher) matchPatterns(patterns []string) ([]string, error) { + var matches []string + for _, pattern := range patterns { + if !strings.Contains(pattern, "/") { + for _, name := range m.files { + matched, err := path.Match(pattern, name) + if err != nil { + return nil, err + } + if matched { + matches = append(matches, name) + } + } + continue + } + + found, err := fs.Glob(m.fsys, joinFSPath(m.dir, pattern)) + if err != nil { + return nil, err + } + for _, name := range found { + info, err := fs.Stat(m.fsys, name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + return nil, err + } + if !info.IsDir() { + matches = append(matches, trimFSDir(m.dir, name)) + } + } + } + return matches, nil +} + +func joinFSPath(dir, name string) string { + if dir == "." { + return name + } + return path.Join(dir, name) +} + +func trimFSDir(dir, name string) string { + if dir == "." { + return name + } + return strings.TrimPrefix(name, path.Clean(dir)+"/") +} + +func first(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} + +func newFileDetection( + def *definitions.Definition, + dir string, + manifest string, + lockfile string, +) FileDetection { + return FileDetection{ + Manager: def.Name, + Ecosystem: def.Ecosystem, + Directory: dir, + Manifest: manifest, + Lockfile: lockfile, + } } func (d *Detector) detectExplicit(dir, managerName string, requireCLI bool) (Manager, error) { //nolint:ireturn diff --git a/detector_test.go b/detector_test.go index d0bfc84..263758e 100644 --- a/detector_test.go +++ b/detector_test.go @@ -1,11 +1,432 @@ package managers import ( + "errors" + "os" + "reflect" "testing" + "testing/fstest" "github.com/git-pkgs/managers/definitions" ) +func TestDetectorDetectFS(t *testing.T) { + detector := newEmbeddedDetector(t) + project := fstest.MapFS{ + "services/web/package.json": {Data: []byte(`{"name":"web"}`)}, + "services/web/package-lock.json": {Data: []byte(`{"lockfileVersion":3}`)}, + } + + got, err := detector.DetectFS(project, "services/web", FileDetectOptions{}) + if err != nil { + t.Fatalf("DetectFS: %v", err) + } + + want := FileDetection{ + Manager: "npm", + Ecosystem: "npm", + Directory: "services/web", + Manifest: "package.json", + Lockfile: "package-lock.json", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("DetectFS result = %#v, want %#v", got, want) + } +} + +func TestDetectorDetectFSUsesDefaultForManifestOnly(t *testing.T) { + detector := newEmbeddedDetector(t) + project := fstest.MapFS{ + "package.json": {Data: []byte(`{"name":"web"}`)}, + } + + got, err := detector.DetectFS(project, ".", FileDetectOptions{}) + if err != nil { + t.Fatalf("DetectFS: %v", err) + } + if got.Manager != "npm" { + t.Fatalf("manager = %q, want %q", got.Manager, "npm") + } +} + +func TestDetectorDetectFSMatchesManifestPatterns(t *testing.T) { + detector := NewDetector(NewTranslator(), NewMockRunner()) + detector.Register(&definitions.Definition{ + Name: "gem", + Ecosystem: "rubygems", + Detection: definitions.Detection{Manifests: []string{"*.gemspec"}}, + }) + + got, err := detector.DetectFS(fstest.MapFS{ + "example.gemspec": {Data: []byte("Gem::Specification.new")}, + }, ".", FileDetectOptions{}) + if err != nil { + t.Fatalf("DetectFS: %v", err) + } + if got.Manifest != "example.gemspec" { + t.Fatalf("manifest = %q, want %q", got.Manifest, "example.gemspec") + } +} + +func TestDetectorDetectFSChecksFileContents(t *testing.T) { + detector := newEmbeddedDetector(t) + project := fstest.MapFS{ + "pyproject.toml": {Data: []byte("[tool.poetry]\nname = 'example'\n")}, + } + + got, err := detector.DetectFS(project, ".", FileDetectOptions{}) + if err != nil { + t.Fatalf("DetectFS: %v", err) + } + if got.Manager != "poetry" { + t.Fatalf("manager = %q, want %q", got.Manager, "poetry") + } +} + +func TestDetectorDetectFSSkipsDanglingFileCheckSymlink(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(dir+"/package-lock.json", []byte(`{"lockfileVersion":3}`), 0o600); err != nil { + t.Fatalf("write package-lock.json: %v", err) + } + if err := os.Symlink("missing", dir+"/pyproject.toml"); err != nil { + t.Fatalf("symlink pyproject.toml: %v", err) + } + + got, err := newEmbeddedDetector(t).DetectFS(os.DirFS(dir), ".", FileDetectOptions{}) + if err != nil { + t.Fatalf("DetectFS: %v", err) + } + if got.Manager != "npm" { + t.Fatalf("manager = %q, want %q", got.Manager, "npm") + } +} + +func TestDetectorDetectFSSkipsDanglingNestedManifestSymlink(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(dir+"/package-lock.json", []byte(`{"lockfileVersion":3}`), 0o600); err != nil { + t.Fatalf("write package-lock.json: %v", err) + } + if err := os.Mkdir(dir+"/project", 0o700); err != nil { + t.Fatalf("mkdir project: %v", err) + } + if err := os.Symlink("missing", dir+"/project/build.properties"); err != nil { + t.Fatalf("symlink project/build.properties: %v", err) + } + + got, err := newEmbeddedDetector(t).DetectFS(os.DirFS(dir), ".", FileDetectOptions{}) + if err != nil { + t.Fatalf("DetectFS: %v", err) + } + if got.Manager != "npm" { + t.Fatalf("manager = %q, want %q", got.Manager, "npm") + } +} + +func TestDetectorDetectFSReportsConflictingLockfiles(t *testing.T) { + detector := newEmbeddedDetector(t) + project := fstest.MapFS{ + "package.json": {Data: []byte(`{"name":"example"}`)}, + "package-lock.json": {Data: []byte(`{"lockfileVersion":3}`)}, + "pnpm-lock.yaml": {Data: []byte("lockfileVersion: '9.0'")}, + } + + _, err := detector.DetectFS(project, ".", FileDetectOptions{}) + var conflict ErrConflictingLockfiles + if !errors.As(err, &conflict) { + t.Fatalf("error = %v, want ErrConflictingLockfiles", err) + } + if want := []string{"pnpm-lock.yaml", "package-lock.json"}; !reflect.DeepEqual(conflict.Lockfiles, want) { + t.Fatalf("lockfiles = %v, want %v", conflict.Lockfiles, want) + } +} + +func TestDetectorDetectFSUsesPriorityWhenConflictAllowed(t *testing.T) { + detector := newEmbeddedDetector(t) + project := fstest.MapFS{ + "package.json": {Data: []byte(`{"name":"example"}`)}, + "package-lock.json": {Data: []byte(`{"lockfileVersion":3}`)}, + "pnpm-lock.yaml": {Data: []byte("lockfileVersion: '9.0'")}, + } + + got, err := detector.DetectFS(project, ".", FileDetectOptions{OnConflict: ConflictUseFirst}) + if err != nil { + t.Fatalf("DetectFS: %v", err) + } + if got.Manager != "pnpm" || got.Lockfile != "pnpm-lock.yaml" { + t.Fatalf("DetectFS result = %#v, want pnpm lockfile", got) + } +} + +func TestDetectorDetectAllFSReturnsEachEcosystem(t *testing.T) { + detector := newEmbeddedDetector(t) + project := fstest.MapFS{ + "go.mod": {Data: []byte("module example.com/project")}, + "go.sum": {Data: []byte("example.com/dependency v1.0.0 h1:hash")}, + "package.json": {Data: []byte(`{"name":"example"}`)}, + "package-lock.json": {Data: []byte(`{"lockfileVersion":3}`)}, + } + + got, err := detector.DetectAllFS(project, ".", FileDetectOptions{}) + if err != nil { + t.Fatalf("DetectAllFS: %v", err) + } + + want := []FileDetection{ + { + Manager: "npm", + Ecosystem: "npm", + Directory: ".", + Manifest: "package.json", + Lockfile: "package-lock.json", + }, + { + Manager: "gomod", + Ecosystem: "go", + Directory: ".", + Manifest: "go.mod", + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("DetectAllFS result = %#v, want %#v", got, want) + } +} + +func TestDetectorDetectAllFSPreservesSelectionPolicy(t *testing.T) { + detector := newEmbeddedDetector(t) + tests := []struct { + name string + project fstest.MapFS + want []FileDetection + }{ + { + name: "ecosystem order and names", + project: fstest.MapFS{ + "package-lock.json": {Data: []byte(`{"lockfileVersion":3}`)}, + "Gemfile.lock": {Data: []byte("GEM\n")}, + "Cargo.toml": {Data: []byte("[package]\n")}, + "go.mod": {Data: []byte("module example.com/app\n")}, + "go.sum": {Data: []byte{}}, + }, + want: []FileDetection{ + {Manager: "npm", Ecosystem: "npm", Directory: ".", Lockfile: "package-lock.json"}, + {Manager: "bundler", Ecosystem: "rubygems", Directory: ".", Lockfile: "Gemfile.lock"}, + {Manager: "cargo", Ecosystem: "cargo", Directory: ".", Manifest: "Cargo.toml"}, + {Manager: "gomod", Ecosystem: "go", Directory: ".", Manifest: "go.mod"}, + }, + }, + { + name: "manifest defaults", + project: fstest.MapFS{ + "package.json": {Data: []byte(`{"name":"app"}`)}, + "pyproject.toml": {Data: []byte("[project]\nname = 'app'\n")}, + "DESCRIPTION": {Data: []byte("Package: app\n")}, + }, + want: []FileDetection{ + {Manager: "npm", Ecosystem: "npm", Directory: ".", Manifest: "package.json"}, + {Manager: "uv", Ecosystem: "pypi", Directory: ".", Manifest: "pyproject.toml"}, + {Manager: "renv", Ecosystem: "cran", Directory: ".", Manifest: "DESCRIPTION"}, + }, + }, + { + name: "lockfiles bypass file checks and use priority", + project: fstest.MapFS{ + "yarn.lock": {Data: []byte("__metadata:\n")}, + ".yarnrc.yml": {Data: []byte("nodeLinker: node-modules\n")}, + "uv.lock": {Data: []byte("version = 1\n")}, + "poetry.lock": {Data: []byte("package = []\n")}, + "cabal.project.freeze": {Data: []byte("constraints: any.base\n")}, + "stack.yaml.lock": {Data: []byte("packages: []\n")}, + }, + want: []FileDetection{ + {Manager: "yarn", Ecosystem: "npm", Directory: ".", Lockfile: "yarn.lock"}, + {Manager: "uv", Ecosystem: "pypi", Directory: ".", Lockfile: "uv.lock"}, + {Manager: "cabal", Ecosystem: "hackage", Directory: ".", Lockfile: "cabal.project.freeze"}, + }, + }, + { + name: "detection-only definitions", + project: fstest.MapFS{ + "Manifest.toml": {Data: []byte("manifest_format = '2.0'\n")}, + "Cartfile.resolved": {Data: []byte("github example/repo 1.0.0\n")}, + "elm.json": {Data: []byte(`{"type":"application"}`)}, + "dub.json": {Data: []byte(`{"name":"app"}`)}, + "haxelib.json": {Data: []byte(`{"name":"app"}`)}, + "flake.lock": {Data: []byte(`{"version":7}`)}, + }, + want: []FileDetection{ + {Manager: "pkg", Ecosystem: "julia", Directory: ".", Lockfile: "Manifest.toml"}, + {Manager: "carthage", Ecosystem: "carthage", Directory: ".", Lockfile: "Cartfile.resolved"}, + {Manager: "elm", Ecosystem: "elm", Directory: ".", Manifest: "elm.json"}, + {Manager: "dub", Ecosystem: "dub", Directory: ".", Manifest: "dub.json"}, + {Manager: "haxelib", Ecosystem: "haxelib", Directory: ".", Manifest: "haxelib.json"}, + {Manager: "nix", Ecosystem: "nix", Directory: ".", Lockfile: "flake.lock"}, + }, + }, + { + name: "nuget and manifest-only vcpkg", + project: fstest.MapFS{ + "packages.config": {Data: []byte("")}, + "project.assets.json": {Data: []byte(`{"version":3}`)}, + "vcpkg.json": {Data: []byte(`{"name":"app"}`)}, + }, + want: []FileDetection{ + {Manager: "nuget", Ecosystem: "nuget", Directory: ".", Manifest: "packages.config", Lockfile: "project.assets.json"}, + {Manager: "vcpkg", Ecosystem: "vcpkg", Directory: ".", Manifest: "vcpkg.json"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := detector.DetectAllFS(test.project, ".", FileDetectOptions{ + OnConflict: ConflictUseFirst, + Policy: GitPkgsFileDetectionPolicy(), + }) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("DetectAllFS = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestGitPkgsFileDetectionPolicyKeepsExpansionsSeparate(t *testing.T) { + detector := newEmbeddedDetector(t) + project := fstest.MapFS{ + "bun.lockb": {Data: []byte{}}, + "npm-shrinkwrap.json": {Data: []byte(`{"lockfileVersion":3}`)}, + "deno.jsonc": {Data: []byte("{}")}, + "Chart.lock": {Data: []byte("dependencies: []\n")}, + "Brewfile": {Data: []byte("brew 'git'\n")}, + } + + got, err := detector.DetectAllFS(project, ".", FileDetectOptions{ + OnConflict: ConflictUseFirst, + Policy: GitPkgsFileDetectionPolicy(), + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("DetectAllFS = %#v, want no detections", got) + } +} + +func TestGitPkgsFileDetectionPolicyMatchesCabalManifest(t *testing.T) { + detector := newEmbeddedDetector(t) + got, err := detector.DetectAllFS( + fstest.MapFS{"app.cabal": {Data: []byte("name: app\n")}}, + ".", + FileDetectOptions{Policy: GitPkgsFileDetectionPolicy()}, + ) + if err != nil { + t.Fatal(err) + } + want := []FileDetection{{ + Manager: "cabal", + Ecosystem: "hackage", + Directory: ".", + Manifest: "app.cabal", + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("DetectAllFS = %#v, want %#v", got, want) + } +} + +func TestDetectorEcosystem(t *testing.T) { + detector := newEmbeddedDetector(t) + for _, test := range []struct { + manager string + ecosystem string + }{ + {manager: "bundler", ecosystem: "rubygems"}, + {manager: "gomod", ecosystem: "go"}, + {manager: "pkg", ecosystem: "julia"}, + } { + got, ok := detector.Ecosystem(test.manager) + if !ok || got != test.ecosystem { + t.Fatalf("Ecosystem(%q) = %q, %t; want %q, true", test.manager, got, ok, test.ecosystem) + } + } + if got, ok := detector.Ecosystem("missing"); ok || got != "" { + t.Fatalf("Ecosystem(%q) = %q, %t; want empty result", "missing", got, ok) + } +} + +func TestDetectorDetectAllFSRejectsGoSumAlone(t *testing.T) { + detector := newEmbeddedDetector(t) + got, err := detector.DetectAllFS(fstest.MapFS{"go.sum": {Data: []byte{}}}, ".", FileDetectOptions{}) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("DetectAllFS = %#v, want no detections", got) + } +} + +func TestDetectorDetectAllFSUsesDefaultForManifestOnly(t *testing.T) { + detector := newEmbeddedDetector(t) + project := fstest.MapFS{ + "package.json": {Data: []byte(`{"name":"example"}`)}, + } + + got, err := detector.DetectAllFS(project, ".", FileDetectOptions{}) + if err != nil { + t.Fatalf("DetectAllFS: %v", err) + } + if len(got) != 1 || got[0].Manager != "npm" { + t.Fatalf("DetectAllFS result = %#v, want npm", got) + } +} + +func TestDetectorAffectedDirectories(t *testing.T) { + detector := newEmbeddedDetector(t) + tests := []struct { + path string + want []string + }{ + {path: "package.json", want: []string{"."}}, + {path: "apps/web/package.json", want: []string{"apps/web"}}, + {path: "example.gemspec", want: []string{"."}}, + {path: "service/project/build.properties", want: []string{"service"}}, + {path: "apps/web/.yarnrc.yml", want: []string{"apps/web"}}, + {path: "apps/web/main.go"}, + } + + for _, test := range tests { + t.Run(test.path, func(t *testing.T) { + got, err := detector.AffectedDirectories(test.path) + if err != nil { + t.Fatalf("AffectedDirectories: %v", err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("AffectedDirectories = %v, want %v", got, test.want) + } + }) + } +} + +func TestDetectorDetectUsesFilesystemDetection(t *testing.T) { + detector := newEmbeddedDetector(t) + dir := t.TempDir() + if err := os.WriteFile(dir+"/package.json", []byte(`{"name":"example"}`), 0o600); err != nil { + t.Fatalf("write package.json: %v", err) + } + if err := os.WriteFile(dir+"/package-lock.json", []byte(`{"lockfileVersion":3}`), 0o600); err != nil { + t.Fatalf("write package-lock.json: %v", err) + } + + manager, err := detector.Detect(dir, DetectOptions{}) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if manager.Name() != "npm" { + t.Fatalf("manager = %q, want %q", manager.Name(), "npm") + } +} + func TestDetectExplicit_RequireCLI(t *testing.T) { translator := NewTranslator() runner := NewMockRunner() @@ -52,3 +473,18 @@ func isErrCLINotFound(err error, target *ErrCLINotFound) bool { } return ok } + +func newEmbeddedDetector(t *testing.T) *Detector { + t.Helper() + + defs, err := definitions.LoadEmbedded() + if err != nil { + t.Fatalf("LoadEmbedded: %v", err) + } + + detector := NewDetector(NewTranslator(), NewMockRunner()) + for _, def := range defs { + detector.Register(def) + } + return detector +} diff --git a/replace.go b/replace.go index af37041..f2f0a10 100644 --- a/replace.go +++ b/replace.go @@ -124,7 +124,7 @@ func (m *GenericManager) buildReplaceInputs(pkg string, mode replaceMode, opts R switch m.def.Name { case "gomod": return buildGoReplaceInputs(pkg, mode, opts) - case "npm", "pnpm", "yarn", "bun": + case npmManagerName, "pnpm", "yarn", "bun": return buildNPMReplaceInputs(m.def.Name, pkg, mode, opts) case "composer": return buildComposerReplaceInputs(pkg, mode, opts)