Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func main() {
| nix | flake.nix | flake.lock, sources.json |
| pre-commit | .pre-commit-config.yaml, prek.toml | |
| npm | package.json | package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, bun.lock, npm-ls.json |
| nuget | *.csproj, *.vbproj, *.fsproj, *.nuspec, packages.config, Project.json | packages.lock.json, paket.lock, project.assets.json, *.deps.json, Project.lock.json |
| nuget | *.csproj, *.vbproj, *.fsproj, *.nuspec, packages.config, Directory.Packages.props, Project.json | packages.lock.json, paket.lock, project.assets.json, *.deps.json, Project.lock.json |
| opam | opam, *.opam | |
| pub | pubspec.yaml | pubspec.lock |
| pypi | requirements.txt, Pipfile, pyproject.toml, setup.py, setup.cfg | Pipfile.lock, poetry.lock, pdm.lock, uv.lock, pip-dependency-graph.json, pip-resolved-dependencies.txt, pylock.toml |
Expand Down Expand Up @@ -254,6 +254,7 @@ type Declaration struct {
Name string // Package name
Version string // Version requirement as written in the manifest
Scope Scope // runtime, development, test, build, optional
Direct bool // Direct rather than generated or transitive
PURL string // Versionless Package URL
Location string // Opaque parser-defined identity within the manifest
}
Expand All @@ -263,14 +264,21 @@ Declarations preserve source-level references without applying inheritance,
merging, interpolation, or other effective-model resolution. Consumers can use
`Location` to match the same logical entry across edits, but should not parse
its ecosystem-specific value. A declaration PURL omits the version because the
raw requirement may be a range or property expression.
raw requirement may be a range or property expression. When a parser supplies
its own PURL, `Parse` preserves it so one manifest can refer to packages from
different ecosystems. Otherwise `Parse` builds the PURL from the parser's
ecosystem.

`Direct` distinguishes explicit requirements from generated or transitive
entries when the source format records that distinction, such as `go.mod`.

Parsers that do not preserve source locations leave `Declarations` empty.
Declarations are available for `package.json`, Python requirements files,
`pyproject.toml`, GitHub Actions workflows, and `pom.xml`. The Maven parser
includes parents, dependencies, dependency management, plugins, plugin
dependencies, plugin management, build extensions, and their profile-scoped
forms.
Declarations are available for `package.json`, Cargo manifests, `go.mod`, Python
requirements files, `pyproject.toml`, GitHub Actions workflows, `gleam.toml`,
`pom.xml`, NuGet project and package files, and
`Directory.Packages.props`. The Maven parser includes parents,
dependencies, dependency management, plugins, plugin dependencies, plugin
management, build extensions, and their profile-scoped forms.

### ParseResult

Expand Down
120 changes: 82 additions & 38 deletions internal/cargo/cargo.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cargo

import (
"net/url"
"strings"

"github.com/BurntSushi/toml"
Expand Down Expand Up @@ -29,54 +30,37 @@ func (p *cargoTomlParser) Parse(filename string, content []byte) (*core.Result,
Dependencies map[string]any `toml:"dependencies"`
DevDependencies map[string]any `toml:"dev-dependencies"`
BuildDependencies map[string]any `toml:"build-dependencies"`
Target map[string]struct {
Dependencies map[string]any `toml:"dependencies"`
DevDependencies map[string]any `toml:"dev-dependencies"`
BuildDependencies map[string]any `toml:"build-dependencies"`
} `toml:"target"`
Workspace struct {
Dependencies map[string]any `toml:"dependencies"`
} `toml:"workspace"`
}

if _, err := toml.Decode(string(content), &cargo); err != nil {
return nil, &core.ParseError{Filename: filename, Err: err}
}

var deps []core.Dependency
var declarations []core.Declaration
pkgName := cargo.Package.Name

for name, value := range cargo.Dependencies {
version := extractCargoVersion(value)
// Skip local path dependencies
if isLocalCargoDep(value) {
continue
}
deps = append(deps, core.Dependency{
Name: name,
Version: version,
Scope: core.Runtime,
Direct: true,
})
}

for name, value := range cargo.DevDependencies {
version := extractCargoVersion(value)
if isLocalCargoDep(value) {
continue
}
deps = append(deps, core.Dependency{
Name: name,
Version: version,
Scope: core.Development,
Direct: true,
})
}

for name, value := range cargo.BuildDependencies {
version := extractCargoVersion(value)
if isLocalCargoDep(value) {
continue
}
deps = append(deps, core.Dependency{
Name: name,
Version: version,
Scope: core.Build,
Direct: true,
})
collectCargoDependencies(&deps, cargo.Dependencies, core.Runtime)
collectCargoDependencies(&deps, cargo.DevDependencies, core.Development)
collectCargoDependencies(&deps, cargo.BuildDependencies, core.Build)
collectCargoDeclarations(&declarations, "dependencies", cargo.Dependencies, core.Runtime, pkgName)
collectCargoDeclarations(&declarations, "dev-dependencies", cargo.DevDependencies, core.Development, pkgName)
collectCargoDeclarations(&declarations, "build-dependencies", cargo.BuildDependencies, core.Build, pkgName)
for target, groups := range cargo.Target {
prefix := "target/" + url.PathEscape(target) + "/"
collectCargoDeclarations(&declarations, prefix+"dependencies", groups.Dependencies, core.Runtime, pkgName)
collectCargoDeclarations(&declarations, prefix+"dev-dependencies", groups.DevDependencies, core.Development, pkgName)
collectCargoDeclarations(&declarations, prefix+"build-dependencies", groups.BuildDependencies, core.Build, pkgName)
}
collectCargoDeclarations(&declarations, "workspace/dependencies", cargo.Workspace.Dependencies, core.Runtime, pkgName)

// Filter out self-reference
filtered := deps[:0]
Expand All @@ -96,9 +80,69 @@ func (p *cargoTomlParser) Parse(filename string, content []byte) (*core.Result,
Licenses: licenses,
LicenseFile: cargo.Package.LicenseFile,
Dependencies: filtered,
Declarations: declarations,
}, nil
}

// collectCargoDependencies appends the dependency inventory from one Cargo
// dependency table.
func collectCargoDependencies(dependencies *[]core.Dependency, values map[string]any, scope core.Scope) {
for name, value := range values {
if isLocalCargoDep(value) {
continue
}
*dependencies = append(*dependencies, core.Dependency{
Name: name,
Version: extractCargoVersion(value),
Scope: scope,
Direct: true,
})
}
}

// collectCargoDeclarations appends source declarations from one Cargo
// dependency table.
func collectCargoDeclarations(
declarations *[]core.Declaration,
prefix string,
values map[string]any,
scope core.Scope,
selfName string,
) {
for name, value := range values {
version := extractCargoVersion(value)
declaredName, ok := cargoRegistryDeclaration(name, value)
if !ok || declaredName == selfName {
continue
}
*declarations = append(*declarations, core.Declaration{
Name: declaredName,
Version: version,
Scope: scope,
Direct: true,
Location: prefix + "/" + url.PathEscape(name),
})
}
}

// cargoRegistryDeclaration returns the registry package name for a dependency.
// Local, git, workspace and named-registry sources are not registry-checkable.
func cargoRegistryDeclaration(name string, value any) (string, bool) {
properties, ok := value.(map[string]any)
if !ok {
return name, true
}
for _, source := range []string{"git", "path", "registry", "workspace"} {
if _, found := properties[source]; found {
return "", false
}
}
if packageName, ok := properties["package"].(string); ok && packageName != "" {
return packageName, true
}
return name, true
}

func extractCargoVersion(value any) string {
switch v := value.(type) {
case string:
Expand Down
65 changes: 65 additions & 0 deletions internal/cargo/cargo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,71 @@ func TestCargoToml(t *testing.T) {
}
}

func TestCargoTomlDeclarations(t *testing.T) {
content := []byte(`[package]
name = "application"

[dependencies]
serde = "=1.0.0"
alias = { package = "actual", version = "=2.0.0" }
local = { path = "../local", version = "=3.0.0" }
git-dep = { git = "https://example.com/repo.git", version = "=4.0.0" }
private = { registry = "private", version = "=5.0.0" }

[dev-dependencies]
serde = "=1.1.0"

[build-dependencies]
cc = "=1.2.0"

[target.'cfg(unix)'.dependencies]
libc = "=0.2.0"

[workspace.dependencies]
anyhow = "=1.0.0"
`)

parser := &cargoTomlParser{}
result, err := parser.Parse("Cargo.toml", content)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}

want := map[string]struct {
name string
version string
scope core.Scope
}{
"dependencies/serde": {"serde", "=1.0.0", core.Runtime},
"dependencies/alias": {"actual", "=2.0.0", core.Runtime},
"dev-dependencies/serde": {"serde", "=1.1.0", core.Development},
"build-dependencies/cc": {"cc", "=1.2.0", core.Build},
"target/cfg%28unix%29/dependencies/libc": {"libc", "=0.2.0", core.Runtime},
"workspace/dependencies/anyhow": {"anyhow", "=1.0.0", core.Runtime},
}
if len(result.Declarations) != len(want) {
t.Fatalf("Declarations has %d entries, want %d: %+v", len(result.Declarations), len(want), result.Declarations)
}
for _, declaration := range result.Declarations {
expected, ok := want[declaration.Location]
if !ok {
t.Errorf("unexpected declaration: %+v", declaration)
continue
}
if declaration.Name != expected.name || declaration.Version != expected.version || declaration.Scope != expected.scope {
t.Errorf("declaration at %q = %+v, want %+v", declaration.Location, declaration, expected)
}
}
if len(result.Dependencies) != 6 {
t.Fatalf("Dependencies has %d entries, want 6: %+v", len(result.Dependencies), result.Dependencies)
}
for _, dependency := range result.Dependencies {
if dependency.Name == "libc" || dependency.Name == "anyhow" {
t.Errorf("target and workspace declarations should not widen Dependencies: %+v", dependency)
}
}
}

func TestCargoLock(t *testing.T) {
content, err := os.ReadFile("../../testdata/cargo/Cargo.lock")
if err != nil {
Expand Down
15 changes: 14 additions & 1 deletion internal/core/helpers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
package core

import "strings"
import (
"strconv"
"strings"
)

// NextLocation returns base with a numeric suffix when seen already contains
// it, so repeated declarations at the same logical position stay addressable.
func NextLocation(seen map[string]int, base string) string {
seen[base]++
if seen[base] > 1 {
return base + "/" + strconv.Itoa(seen[base])
}
return base
}

// ForEachLine iterates over lines in content without allocating a slice.
func ForEachLine(content string, fn func(line string) bool) {
Expand Down
11 changes: 8 additions & 3 deletions internal/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ type Dependency struct {
// before effective-model resolution or inheritance. Location is
// ecosystem-specific and should be treated as an opaque identity.
type Declaration struct {
Name string
Version string
Scope Scope
Name string
Version string
Scope Scope
// Direct reports whether the manifest declares the package as a direct
// dependency rather than a generated or transitive requirement.
Direct bool
// PURL identifies the declared package without a version. Parsers may set
// it when a file can contain references from more than one ecosystem.
PURL string
Location string
}
Expand Down
9 changes: 2 additions & 7 deletions internal/github_actions/github_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package github_actions
import (
"net/url"
"path/filepath"
"strconv"
"strings"

"github.com/git-pkgs/manifests/internal/core"
Expand Down Expand Up @@ -95,16 +94,12 @@ func collectStepActions(
continue
}
if !strings.HasPrefix(name, "docker://") {
base := "jobs/" + url.PathEscape(jobName) + "/steps/" + url.PathEscape(name)
locations[base]++
location := base
if locations[base] > 1 {
location += "/" + strconv.Itoa(locations[base])
}
location := core.NextLocation(locations, "jobs/"+url.PathEscape(jobName)+"/steps/"+url.PathEscape(name))
*declarations = append(*declarations, core.Declaration{
Name: name,
Version: version,
Scope: core.Runtime,
Direct: true,
Location: location,
})
}
Expand Down
Loading