diff --git a/README.md b/README.md
index 7a439bf..d1fe6f4 100644
--- a/README.md
+++ b/README.md
@@ -76,8 +76,9 @@ func main() {
| pre-commit | .pre-commit-config.yaml, prek.toml | |
| npm | package.json, bower.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 |
+| opam | opam, *.opam | |
| pub | pubspec.yaml | pubspec.lock |
-| pypi | requirements.txt, Pipfile, pyproject.toml, setup.py | Pipfile.lock, poetry.lock, pdm.lock, uv.lock, pip-dependency-graph.json, pip-resolved-dependencies.txt, pylock.toml |
+| 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 |
| rpm | *.spec | |
| swift | Package.swift | Package.resolved |
| vcpkg | vcpkg.json | |
@@ -184,12 +185,16 @@ type ParseResult struct {
Kind Kind // manifest, lockfile, or supplement
Name string // the package's own name, when the format declares one
Version string // the package's own version, when declared
+ Licenses []string // raw declared license values
+ LicenseFile string // manifest-relative path to a declared license file
Dependencies []Dependency
}
```
`Name` and `Version` are populated for manifest formats that declare their own package identity (Cargo.toml `[package]`, package.json `"name"`, go.mod `module`, `.gemspec`, and so on). They are empty for lockfiles and for dependency-only files like Gemfile or requirements.txt.
+`Licenses` contains decoded values as declared by the manifest; it does not normalize them into SPDX expressions. `LicenseFile` is populated when a format explicitly identifies a license file. Both are empty for formats without license metadata.
+
### Kind
```go
diff --git a/imports.go b/imports.go
index 20abd46..e4efe63 100644
--- a/imports.go
+++ b/imports.go
@@ -38,6 +38,7 @@ import (
_ "github.com/git-pkgs/manifests/internal/nix"
_ "github.com/git-pkgs/manifests/internal/npm"
_ "github.com/git-pkgs/manifests/internal/nuget"
+ _ "github.com/git-pkgs/manifests/internal/opam"
_ "github.com/git-pkgs/manifests/internal/precommit"
_ "github.com/git-pkgs/manifests/internal/pub"
_ "github.com/git-pkgs/manifests/internal/pypi"
diff --git a/internal/cargo/cargo.go b/internal/cargo/cargo.go
index fcc2287..4e46976 100644
--- a/internal/cargo/cargo.go
+++ b/internal/cargo/cargo.go
@@ -21,8 +21,10 @@ type cargoTomlParser struct{}
func (p *cargoTomlParser) Parse(filename string, content []byte) (*core.Result, error) {
var cargo struct {
Package struct {
- Name string `toml:"name"`
- Version string `toml:"version"`
+ Name string `toml:"name"`
+ Version string `toml:"version"`
+ License string `toml:"license"`
+ LicenseFile string `toml:"license-file"`
} `toml:"package"`
Dependencies map[string]any `toml:"dependencies"`
DevDependencies map[string]any `toml:"dev-dependencies"`
@@ -84,7 +86,17 @@ func (p *cargoTomlParser) Parse(filename string, content []byte) (*core.Result,
}
}
- return &core.Result{Name: pkgName, Version: cargo.Package.Version, Dependencies: filtered}, nil
+ var licenses []string
+ if cargo.Package.License != "" {
+ licenses = []string{cargo.Package.License}
+ }
+ return &core.Result{
+ Name: pkgName,
+ Version: cargo.Package.Version,
+ Licenses: licenses,
+ LicenseFile: cargo.Package.LicenseFile,
+ Dependencies: filtered,
+ }, nil
}
func extractCargoVersion(value any) string {
diff --git a/internal/cocoapods/cocoapods.go b/internal/cocoapods/cocoapods.go
index 946e11a..aa698e7 100644
--- a/internal/cocoapods/cocoapods.go
+++ b/internal/cocoapods/cocoapods.go
@@ -170,6 +170,12 @@ var (
podspecNameRegex = regexp.MustCompile(`\.name\s*=\s*["']([^"']+)["']`)
// s.version = "1.0.0"
podspecVersionRegex = regexp.MustCompile(`\.version\s*=\s*["']([^"']+)["']`)
+ // s.license = "MIT"
+ podspecLicenseRegex = regexp.MustCompile(`\.license\s*=\s*["']([^"']+)["']`)
+ // s.license = { :type => "MIT", :file => "LICENSE" }
+ podspecLicenseHashRegex = regexp.MustCompile(`(?s)\.license\s*=\s*\{([^}]*)\}`)
+ podspecLicenseTypeRegex = regexp.MustCompile(`(?::type|["']type["']|\btype)\s*(?:=>|:)\s*["']([^"']+)["']`)
+ podspecLicenseFileRegex = regexp.MustCompile(`(?::file|["']file["']|\bfile)\s*(?:=>|:)\s*["']([^"']+)["']`)
)
func (p *podspecParser) Parse(filename string, content []byte) (*core.Result, error) {
@@ -183,6 +189,18 @@ func (p *podspecParser) Parse(filename string, content []byte) (*core.Result, er
if m := podspecVersionRegex.FindStringSubmatch(text); m != nil {
selfVersion = m[1]
}
+ var licenses []string
+ var licenseFile string
+ if m := podspecLicenseRegex.FindStringSubmatch(text); m != nil {
+ licenses = []string{m[1]}
+ } else if hash := podspecLicenseHashRegex.FindStringSubmatch(text); hash != nil {
+ if m := podspecLicenseTypeRegex.FindStringSubmatch(hash[1]); m != nil {
+ licenses = []string{m[1]}
+ }
+ if m := podspecLicenseFileRegex.FindStringSubmatch(hash[1]); m != nil {
+ licenseFile = m[1]
+ }
+ }
for _, match := range podspecDepRegex.FindAllStringSubmatch(text, -1) {
const versionGroup = 2
@@ -199,5 +217,11 @@ func (p *podspecParser) Parse(filename string, content []byte) (*core.Result, er
})
}
- return &core.Result{Name: selfName, Version: selfVersion, Dependencies: deps}, nil
+ return &core.Result{
+ Name: selfName,
+ Version: selfVersion,
+ Licenses: licenses,
+ LicenseFile: licenseFile,
+ Dependencies: deps,
+ }, nil
}
diff --git a/internal/composer/composer.go b/internal/composer/composer.go
index bae19d4..9079ba0 100644
--- a/internal/composer/composer.go
+++ b/internal/composer/composer.go
@@ -16,6 +16,7 @@ type composerJSONParser struct{}
type composerJSON struct {
Name string `json:"name"`
Version string `json:"version"`
+ License any `json:"license"`
Require map[string]string `json:"require"`
RequireDev map[string]string `json:"require-dev"`
}
@@ -59,7 +60,30 @@ func (p *composerJSONParser) Parse(filename string, content []byte) (*core.Resul
})
}
- return &core.Result{Name: composer.Name, Version: composer.Version, Dependencies: deps}, nil
+ return &core.Result{
+ Name: composer.Name,
+ Version: composer.Version,
+ Licenses: composerLicenses(composer.License),
+ Dependencies: deps,
+ }, nil
+}
+
+func composerLicenses(value any) []string {
+ switch license := value.(type) {
+ case string:
+ if license != "" {
+ return []string{license}
+ }
+ case []any:
+ licenses := make([]string, 0, len(license))
+ for _, item := range license {
+ if text, ok := item.(string); ok && text != "" {
+ licenses = append(licenses, text)
+ }
+ }
+ return licenses
+ }
+ return nil
}
// composerLockParser parses composer.lock files.
diff --git a/internal/core/types.go b/internal/core/types.go
index 0abfca1..06c4c39 100644
--- a/internal/core/types.go
+++ b/internal/core/types.go
@@ -39,7 +39,12 @@ type Result struct {
// that only list dependencies (Gemfile, requirements.txt, etc.).
Name string
// Version is the package's own version as declared in the manifest.
- Version string
+ Version string
+ // Licenses holds the package's declared license values, without
+ // normalization.
+ Licenses []string
+ // LicenseFile is a manifest-relative path to a declared license file.
+ LicenseFile string
Dependencies []Dependency
}
diff --git a/internal/cran/cran.go b/internal/cran/cran.go
index d006e0a..c869f84 100644
--- a/internal/cran/cran.go
+++ b/internal/cran/cran.go
@@ -70,7 +70,39 @@ func (p *descriptionParser) Parse(filename string, content []byte) (*core.Result
}
}
- return &core.Result{Name: fields["Package"], Version: fields["Version"], Dependencies: deps}, nil
+ licenses, licenseFile := parseRLicense(fields["License"])
+ return &core.Result{
+ Name: fields["Package"],
+ Version: fields["Version"],
+ Licenses: licenses,
+ LicenseFile: licenseFile,
+ Dependencies: deps,
+ }, nil
+}
+
+func parseRLicense(value string) ([]string, string) {
+ var licenses []string
+ var licenseFile string
+ for _, alternative := range strings.Split(value, "|") {
+ alternative = strings.TrimSpace(alternative)
+ if alternative == "" {
+ continue
+ }
+ lower := strings.ToLower(alternative)
+ if idx := strings.Index(lower, "file "); idx >= 0 {
+ if licenseFile == "" {
+ fileFields := strings.Fields(alternative[idx+len("file "):])
+ if len(fileFields) > 0 {
+ licenseFile = fileFields[0]
+ }
+ }
+ alternative = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(alternative[:idx]), "+"))
+ }
+ if alternative != "" {
+ licenses = append(licenses, alternative)
+ }
+ }
+ return licenses, licenseFile
}
// parseDescriptionFields parses DESCRIPTION file key-value pairs.
diff --git a/internal/elm/elm.go b/internal/elm/elm.go
index 0baa8c9..c514a84 100644
--- a/internal/elm/elm.go
+++ b/internal/elm/elm.go
@@ -16,8 +16,9 @@ type elmJSONParser struct{}
type elmJSON struct {
Name string `json:"name"`
Version string `json:"version"`
- Dependencies elmDependencies `json:"dependencies"`
- TestDependencies elmDependencies `json:"test-dependencies"`
+ License string `json:"license"`
+ Dependencies json.RawMessage `json:"dependencies"`
+ TestDependencies json.RawMessage `json:"test-dependencies"`
}
type elmDependencies struct {
@@ -31,55 +32,72 @@ func (p *elmJSONParser) Parse(filename string, content []byte) (*core.Result, er
return nil, &core.ParseError{Filename: filename, Err: err}
}
- var deps []core.Dependency
+ deps, err := parseElmDependencies(elm.Dependencies, core.Runtime)
+ if err != nil {
+ return nil, &core.ParseError{Filename: filename, Err: err}
+ }
+ testDeps, err := parseElmDependencies(elm.TestDependencies, core.Test)
+ if err != nil {
+ return nil, &core.ParseError{Filename: filename, Err: err}
+ }
+ deps = append(deps, testDeps...)
+
+ return &core.Result{
+ Name: elm.Name,
+ Version: elm.Version,
+ Licenses: declaredLicense(elm.License),
+ Dependencies: deps,
+ }, nil
+}
- // Direct dependencies
- for name, version := range elm.Dependencies.Direct {
- deps = append(deps, core.Dependency{
- Name: name,
- Version: version,
- Scope: core.Runtime,
- Direct: true,
- })
+func parseElmDependencies(content json.RawMessage, scope core.Scope) ([]core.Dependency, error) {
+ if len(content) == 0 || string(content) == "null" {
+ return nil, nil
}
- // Indirect dependencies
- for name, version := range elm.Dependencies.Indirect {
- deps = append(deps, core.Dependency{
- Name: name,
- Version: version,
- Scope: core.Runtime,
- Direct: false,
- })
+ var shape map[string]json.RawMessage
+ if err := json.Unmarshal(content, &shape); err != nil {
+ return nil, err
+ }
+ _, hasDirect := shape["direct"]
+ _, hasIndirect := shape["indirect"]
+ if hasDirect || hasIndirect {
+ var groups elmDependencies
+ if err := json.Unmarshal(content, &groups); err != nil {
+ return nil, err
+ }
+ deps := make([]core.Dependency, 0, len(groups.Direct)+len(groups.Indirect))
+ deps = appendElmDependencies(deps, groups.Direct, scope, true)
+ deps = appendElmDependencies(deps, groups.Indirect, scope, false)
+ return deps, nil
}
- // Test dependencies (direct)
- for name, version := range elm.TestDependencies.Direct {
- deps = append(deps, core.Dependency{
- Name: name,
- Version: version,
- Scope: core.Test,
- Direct: true,
- })
+ var packages map[string]string
+ if err := json.Unmarshal(content, &packages); err != nil {
+ return nil, err
}
+ return appendElmDependencies(nil, packages, scope, true), nil
+}
- // Test dependencies (indirect)
- for name, version := range elm.TestDependencies.Indirect {
+func appendElmDependencies(deps []core.Dependency, packages map[string]string, scope core.Scope, direct bool) []core.Dependency {
+ for name, version := range packages {
deps = append(deps, core.Dependency{
Name: name,
Version: version,
- Scope: core.Test,
- Direct: false,
+ Scope: scope,
+ Direct: direct,
})
}
-
- return &core.Result{Name: elm.Name, Version: elm.Version, Dependencies: deps}, nil
+ return deps
}
// elmPackageJSONParser parses elm-package.json files (Elm 0.18 and earlier).
type elmPackageJSONParser struct{}
type elmPackageJSON struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ License string `json:"license"`
Dependencies map[string]string `json:"dependencies"`
}
@@ -100,5 +118,17 @@ func (p *elmPackageJSONParser) Parse(filename string, content []byte) (*core.Res
})
}
- return &core.Result{Dependencies: deps}, nil
+ return &core.Result{
+ Name: elm.Name,
+ Version: elm.Version,
+ Licenses: declaredLicense(elm.License),
+ Dependencies: deps,
+ }, nil
+}
+
+func declaredLicense(value string) []string {
+ if value == "" {
+ return nil
+ }
+ return []string{value}
}
diff --git a/internal/elm/elm_test.go b/internal/elm/elm_test.go
index 2468bbb..b050a7a 100644
--- a/internal/elm/elm_test.go
+++ b/internal/elm/elm_test.go
@@ -90,3 +90,81 @@ func TestElmLegacyJSON(t *testing.T) {
}
}
}
+
+func TestElmJSONPackageDependencies(t *testing.T) {
+ content := []byte(`{
+ "type": "package",
+ "name": "author/example",
+ "version": "1.0.0",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "elm/core": "1.0.0 <= v < 2.0.0"
+ },
+ "test-dependencies": {
+ "elm/json": "1.0.0 <= v < 2.0.0"
+ }
+ }`)
+
+ result, err := (&elmJSONParser{}).Parse("elm.json", content)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ if len(result.Licenses) != 1 || result.Licenses[0] != "BSD-3-Clause" {
+ t.Errorf("Licenses = %q, want [BSD-3-Clause]", result.Licenses)
+ }
+ if len(result.Dependencies) != 2 {
+ t.Fatalf("Dependencies = %d, want 2", len(result.Dependencies))
+ }
+ for _, dependency := range result.Dependencies {
+ if !dependency.Direct {
+ t.Errorf("%s should be direct", dependency.Name)
+ }
+ switch dependency.Name {
+ case "elm/core":
+ if dependency.Scope != core.Runtime {
+ t.Errorf("elm/core scope = %q, want runtime", dependency.Scope)
+ }
+ case "elm/json":
+ if dependency.Scope != core.Test {
+ t.Errorf("elm/json scope = %q, want test", dependency.Scope)
+ }
+ default:
+ t.Errorf("unexpected dependency %q", dependency.Name)
+ }
+ }
+}
+
+func TestElmJSONApplicationDependencies(t *testing.T) {
+ content := []byte(`{
+ "type": "application",
+ "dependencies": {
+ "direct": {"elm/core": "1.0.5"},
+ "indirect": {"elm/json": "1.1.3"}
+ },
+ "test-dependencies": {
+ "direct": {"elm-explorations/test": "2.2.0"},
+ "indirect": {}
+ }
+ }`)
+
+ result, err := (&elmJSONParser{}).Parse("elm.json", content)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ if len(result.Dependencies) != 3 {
+ t.Fatalf("Dependencies = %d, want 3", len(result.Dependencies))
+ }
+ dependencies := make(map[string]core.Dependency)
+ for _, dependency := range result.Dependencies {
+ dependencies[dependency.Name] = dependency
+ }
+ if dependency := dependencies["elm/core"]; !dependency.Direct || dependency.Scope != core.Runtime {
+ t.Errorf("elm/core = %#v", dependency)
+ }
+ if dependency := dependencies["elm/json"]; dependency.Direct || dependency.Scope != core.Runtime {
+ t.Errorf("elm/json = %#v", dependency)
+ }
+ if dependency := dependencies["elm-explorations/test"]; !dependency.Direct || dependency.Scope != core.Test {
+ t.Errorf("elm-explorations/test = %#v", dependency)
+ }
+}
diff --git a/internal/gem/rubygems.go b/internal/gem/rubygems.go
index 2fdf2d7..5f1b126 100644
--- a/internal/gem/rubygems.go
+++ b/internal/gem/rubygems.go
@@ -2,6 +2,7 @@ package gem
import (
"github.com/git-pkgs/manifests/internal/core"
+ "regexp"
"strings"
)
@@ -337,6 +338,12 @@ func (p *gemfileLockParser) Parse(filename string, content []byte) (*core.Result
// gemspecParser parses .gemspec files.
type gemspecParser struct{}
+var (
+ gemspecLicenseRegex = regexp.MustCompile(`(?m)\.license\s*=\s*["']([^"']+)["']`)
+ gemspecLicensesRegex = regexp.MustCompile(`(?s)\.licenses\s*=\s*\[([^\]]*)\]`)
+ rubyQuotedRegex = regexp.MustCompile(`["']([^"']+)["']`)
+)
+
// extractGemspecAttr extracts a string literal from lines like `s.name = "foo"`
// or `spec.version = 'foo'`. Returns empty when the RHS is not a string literal
// (e.g. a constant reference).
@@ -448,5 +455,20 @@ func (p *gemspecParser) Parse(filename string, content []byte) (*core.Result, er
return true
})
- return &core.Result{Name: selfName, Version: selfVersion, Dependencies: deps}, nil
+ var licenses []string
+ if match := gemspecLicenseRegex.FindStringSubmatch(text); match != nil {
+ licenses = append(licenses, match[1])
+ }
+ if match := gemspecLicensesRegex.FindStringSubmatch(text); match != nil {
+ for _, value := range rubyQuotedRegex.FindAllStringSubmatch(match[1], -1) {
+ licenses = append(licenses, value[1])
+ }
+ }
+
+ return &core.Result{
+ Name: selfName,
+ Version: selfVersion,
+ Licenses: licenses,
+ Dependencies: deps,
+ }, nil
}
diff --git a/internal/hackage/hackage.go b/internal/hackage/hackage.go
index 53dfa9c..583f88c 100644
--- a/internal/hackage/hackage.go
+++ b/internal/hackage/hackage.go
@@ -31,6 +31,7 @@ func (p *cabalParser) Parse(filename string, content []byte) (*core.Result, erro
seen := make(map[string]bool)
pkgName, pkgVersion := cabalHeaderFields(lines)
+ licenses, licenseFile := cabalLicenseFields(lines)
inBuildDepends := false
for _, line := range lines {
@@ -89,7 +90,13 @@ func (p *cabalParser) Parse(filename string, content []byte) (*core.Result, erro
}
}
- return &core.Result{Name: pkgName, Version: pkgVersion, Dependencies: deps}, nil
+ return &core.Result{
+ Name: pkgName,
+ Version: pkgVersion,
+ Licenses: licenses,
+ LicenseFile: licenseFile,
+ Dependencies: deps,
+ }, nil
}
// cabalHeaderFields returns the top-level name: and version: values.
@@ -113,6 +120,52 @@ func cabalHeaderFields(lines []string) (name, version string) {
return name, version
}
+func cabalLicenseFields(lines []string) ([]string, string) {
+ var license, licenseFile string
+ inLicenseFiles := false
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ continue
+ }
+ indented := line[0] == ' ' || line[0] == '\t'
+ if inLicenseFiles && indented && licenseFile == "" {
+ licenseFile = firstCabalListValue(trimmed)
+ continue
+ }
+ if indented {
+ continue
+ }
+ inLicenseFiles = false
+ lower := strings.ToLower(line)
+ switch {
+ case strings.HasPrefix(lower, "license:"):
+ license = strings.TrimSpace(line[len("license:"):])
+ case strings.HasPrefix(lower, "license-file:"):
+ if licenseFile == "" {
+ licenseFile = strings.TrimSpace(line[len("license-file:"):])
+ }
+ case strings.HasPrefix(lower, "license-files:"):
+ inLicenseFiles = true
+ if licenseFile == "" {
+ licenseFile = firstCabalListValue(line[len("license-files:"):])
+ }
+ }
+ }
+ if license == "" {
+ return nil, licenseFile
+ }
+ return []string{license}, licenseFile
+}
+
+func firstCabalListValue(value string) string {
+ value = strings.TrimSpace(strings.TrimPrefix(value, ","))
+ if idx := strings.IndexByte(value, ','); idx >= 0 {
+ value = value[:idx]
+ }
+ return strings.TrimSpace(value)
+}
+
// stackLockParser parses stack.yaml.lock files.
type stackLockParser struct{}
diff --git a/internal/hex/hex.go b/internal/hex/hex.go
index dd3bd13..d05e570 100644
--- a/internal/hex/hex.go
+++ b/internal/hex/hex.go
@@ -21,6 +21,10 @@ var (
mixAppRegex = regexp.MustCompile(`\bapp:\s*:([a-zA-Z_][a-zA-Z0-9_]*)`)
// version: "0.0.1" inside def project
mixVersionRegex = regexp.MustCompile(`\bversion:\s*"([^"]+)"`)
+ // licenses: ["MIT", "Apache-2.0"] inside package/0
+ mixLicensesRegex = regexp.MustCompile(`(?s)\blicenses:\s*\[([^\]]*)\]`)
+ mixQuotedRegex = regexp.MustCompile(`["']([^"']+)["']`)
+ mixPackageRegex = regexp.MustCompile(`\bdefp?\s+package(?:\(\))?\s*(?:do|,\s*do:)`)
)
func (p *mixExsParser) Parse(filename string, content []byte) (*core.Result, error) {
@@ -37,6 +41,7 @@ func (p *mixExsParser) Parse(filename string, content []byte) (*core.Result, err
selfVersion = m[1]
}
}
+ licenses := extractMixLicenses(text)
// Find deps function content
depsStart := strings.Index(text, "defp deps do")
@@ -44,7 +49,7 @@ func (p *mixExsParser) Parse(filename string, content []byte) (*core.Result, err
depsStart = strings.Index(text, "def deps do")
}
if depsStart < 0 {
- return &core.Result{Name: selfName, Version: selfVersion, Dependencies: deps}, nil
+ return &core.Result{Name: selfName, Version: selfVersion, Licenses: licenses, Dependencies: deps}, nil
}
section := extractMixBlock(text[depsStart:])
@@ -57,7 +62,24 @@ func (p *mixExsParser) Parse(filename string, content []byte) (*core.Result, err
})
}
- return &core.Result{Name: selfName, Version: selfVersion, Dependencies: deps}, nil
+ return &core.Result{Name: selfName, Version: selfVersion, Licenses: licenses, Dependencies: deps}, nil
+}
+
+func extractMixLicenses(text string) []string {
+ location := mixPackageRegex.FindStringIndex(text)
+ if location == nil {
+ return nil
+ }
+ section := extractMixBlock(text[location[0]:])
+ match := mixLicensesRegex.FindStringSubmatch(section)
+ if match == nil {
+ return nil
+ }
+ var licenses []string
+ for _, value := range mixQuotedRegex.FindAllStringSubmatch(match[1], -1) {
+ licenses = append(licenses, value[1])
+ }
+ return licenses
}
// extractMixBlock returns the bracket-delimited body starting at text.
diff --git a/internal/hex/hex_test.go b/internal/hex/hex_test.go
index fec6d7d..17cd932 100644
--- a/internal/hex/hex_test.go
+++ b/internal/hex/hex_test.go
@@ -94,3 +94,21 @@ func TestMixLock(t *testing.T) {
}
}
}
+
+func TestMixOneLinePackageLicenses(t *testing.T) {
+ content := []byte(`defmodule Example.MixProject do
+ use Mix.Project
+ def project, do: [app: :example, version: "1.0.0", package: package()]
+ defp package(), do: [licenses: ["MIT", "Apache-2.0"]]
+end`)
+
+ result, err := (&mixExsParser{}).Parse("mix.exs", content)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ if len(result.Licenses) != 2 ||
+ result.Licenses[0] != "MIT" ||
+ result.Licenses[1] != "Apache-2.0" {
+ t.Errorf("Licenses = %q, want [MIT Apache-2.0]", result.Licenses)
+ }
+}
diff --git a/internal/maven/maven.go b/internal/maven/maven.go
index 5e6f0c1..65b002c 100644
--- a/internal/maven/maven.go
+++ b/internal/maven/maven.go
@@ -68,7 +68,38 @@ func (p *pomXMLParser) ParseInRoot(filename string, content []byte, fsRoot strin
if ep.GAV.GroupID != "" {
selfName = ep.GAV.GroupID + ":" + ep.GAV.ArtifactID
}
- return &core.Result{Name: selfName, Version: ep.GAV.Version, Dependencies: deps}, nil
+ var licenses []string
+ var licenseFile string
+ for _, license := range ep.Licenses {
+ if name := strings.TrimSpace(license.Name); name != "" {
+ licenses = append(licenses, name)
+ }
+ if licenseFile == "" {
+ licenseFile = relativeLicensePath(license.URL)
+ }
+ }
+ return &core.Result{
+ Name: selfName,
+ Version: ep.GAV.Version,
+ Licenses: licenses,
+ LicenseFile: licenseFile,
+ Dependencies: deps,
+ }, nil
+}
+
+func relativeLicensePath(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" || filepath.IsAbs(value) || strings.Contains(value, "://") || strings.HasPrefix(value, "//") {
+ return ""
+ }
+ base := strings.ToLower(filepath.Base(value))
+ if strings.Contains(base, "license") ||
+ strings.Contains(base, "licence") ||
+ strings.Contains(base, "copying") ||
+ strings.Contains(base, "notice") {
+ return value
+ }
+ return ""
}
func mapScope(scope string, optional bool) core.Scope {
diff --git a/internal/npm/bower.go b/internal/npm/bower.go
index 4f4c843..5bf34bf 100644
--- a/internal/npm/bower.go
+++ b/internal/npm/bower.go
@@ -15,6 +15,7 @@ type bowerParser struct{}
type bowerJSON struct {
Name string `json:"name"`
Version string `json:"version"`
+ License any `json:"license"`
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
}
@@ -45,5 +46,28 @@ func (p *bowerParser) Parse(filename string, content []byte) (*core.Result, erro
})
}
- return &core.Result{Name: bower.Name, Version: bower.Version, Dependencies: deps}, nil
+ return &core.Result{
+ Name: bower.Name,
+ Version: bower.Version,
+ Licenses: bowerLicenses(bower.License),
+ Dependencies: deps,
+ }, nil
+}
+
+func bowerLicenses(value any) []string {
+ switch license := value.(type) {
+ case string:
+ if license != "" {
+ return []string{license}
+ }
+ case []any:
+ licenses := make([]string, 0, len(license))
+ for _, item := range license {
+ if text, ok := item.(string); ok && text != "" {
+ licenses = append(licenses, text)
+ }
+ }
+ return licenses
+ }
+ return nil
}
diff --git a/internal/npm/npm.go b/internal/npm/npm.go
index 7dc27fb..88e2d47 100644
--- a/internal/npm/npm.go
+++ b/internal/npm/npm.go
@@ -24,12 +24,18 @@ type npmPackageJSONParser struct{}
type packageJSON struct {
Name string `json:"name"`
Version string `json:"version"`
+ License any `json:"license"`
+ Licenses []npmLicense `json:"licenses"`
Dependencies map[string]any `json:"dependencies"`
DevDependencies map[string]any `json:"devDependencies"`
OptionalDependencies map[string]any `json:"optionalDependencies"`
PeerDependencies map[string]any `json:"peerDependencies"`
}
+type npmLicense struct {
+ Type string `json:"type"`
+}
+
func (p *npmPackageJSONParser) Parse(filename string, content []byte) (*core.Result, error) {
var pkg packageJSON
if err := json.Unmarshal(content, &pkg); err != nil {
@@ -106,7 +112,32 @@ func (p *npmPackageJSONParser) Parse(filename string, content []byte) (*core.Res
})
}
- return &core.Result{Name: pkg.Name, Version: pkg.Version, Dependencies: deps}, nil
+ return &core.Result{
+ Name: pkg.Name,
+ Version: pkg.Version,
+ Licenses: npmLicenses(pkg.License, pkg.Licenses),
+ Dependencies: deps,
+ }, nil
+}
+
+func npmLicenses(license any, legacy []npmLicense) []string {
+ var licenses []string
+ switch value := license.(type) {
+ case string:
+ if value != "" {
+ licenses = append(licenses, value)
+ }
+ case map[string]any:
+ if valueType, ok := value["type"].(string); ok && valueType != "" {
+ licenses = append(licenses, valueType)
+ }
+ }
+ for _, value := range legacy {
+ if value.Type != "" {
+ licenses = append(licenses, value.Type)
+ }
+ }
+ return licenses
}
// isNpmComment checks if a dependency name is actually a comment.
diff --git a/internal/nuget/nuget.go b/internal/nuget/nuget.go
index e8ab202..589f07a 100644
--- a/internal/nuget/nuget.go
+++ b/internal/nuget/nuget.go
@@ -176,8 +176,9 @@ type nuspecParser struct{}
type nuspecPackage struct {
Metadata struct {
- ID string `xml:"id"`
- Version string `xml:"version"`
+ ID string `xml:"id"`
+ Version string `xml:"version"`
+ License nuspecLicense `xml:"license"`
Dependencies struct {
Groups []nuspecDepGroup `xml:"group"`
Deps []nuspecDep `xml:"dependency"`
@@ -185,6 +186,11 @@ type nuspecPackage struct {
} `xml:"metadata"`
}
+type nuspecLicense struct {
+ Type string `xml:"type,attr"`
+ Value string `xml:",chardata"`
+}
+
type nuspecDepGroup struct {
TargetFramework string `xml:"targetFramework,attr"`
Deps []nuspecDep `xml:"dependency"`
@@ -236,11 +242,21 @@ func (p *nuspecParser) Parse(filename string, content []byte) (*core.Result, err
}
}
- return &core.Result{
+ result := &core.Result{
Name: pkg.Metadata.ID,
Version: pkg.Metadata.Version,
Dependencies: deps,
- }, nil
+ }
+ licenseValue := strings.TrimSpace(pkg.Metadata.License.Value)
+ switch strings.ToLower(strings.TrimSpace(pkg.Metadata.License.Type)) {
+ case "expression":
+ if licenseValue != "" {
+ result.Licenses = []string{licenseValue}
+ }
+ case "file":
+ result.LicenseFile = licenseValue
+ }
+ return result, nil
}
// packagesConfigParser parses packages.config files.
diff --git a/internal/opam/opam.go b/internal/opam/opam.go
new file mode 100644
index 0000000..bb6116f
--- /dev/null
+++ b/internal/opam/opam.go
@@ -0,0 +1,161 @@
+package opam
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/git-pkgs/manifests/internal/core"
+)
+
+func init() {
+ core.Register("opam", core.Manifest, &parser{},
+ core.AnyMatch(core.ExactMatch("opam"), core.SuffixMatch(".opam")))
+}
+
+// parser parses OPAM package definition files.
+type parser struct{}
+
+func (p *parser) Parse(_ string, content []byte) (*core.Result, error) {
+ text := string(content)
+ var deps []core.Dependency
+ for _, name := range opamTopLevelStrings(opamField(text, "depends")) {
+ deps = append(deps, core.Dependency{
+ Name: name,
+ Scope: core.Runtime,
+ Direct: true,
+ })
+ }
+
+ return &core.Result{
+ Name: opamScalar(opamField(text, "name")),
+ Version: opamScalar(opamField(text, "version")),
+ Licenses: opamTopLevelStrings(opamField(text, "license")),
+ Dependencies: deps,
+ }, nil
+}
+
+func opamField(text, field string) string {
+ pattern := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(field) + `[ \t]*:[ \t]*`)
+ location := pattern.FindStringIndex(text)
+ if location == nil {
+ return ""
+ }
+ start := location[1]
+ for start < len(text) && (text[start] == ' ' || text[start] == '\t') {
+ start++
+ }
+ if start >= len(text) {
+ return ""
+ }
+ if text[start] != '[' {
+ if end := strings.IndexByte(text[start:], '\n'); end >= 0 {
+ return strings.TrimSpace(text[start : start+end])
+ }
+ return strings.TrimSpace(text[start:])
+ }
+
+ depth := 0
+ inString := false
+ escaped := false
+ inComment := false
+ for i := start; i < len(text); i++ {
+ switch {
+ case inComment:
+ if text[i] == '\n' {
+ inComment = false
+ }
+ case inString:
+ if escaped {
+ escaped = false
+ } else if text[i] == '\\' {
+ escaped = true
+ } else if text[i] == '"' {
+ inString = false
+ }
+ default:
+ switch text[i] {
+ case '#':
+ inComment = true
+ case '"':
+ inString = true
+ case '[':
+ depth++
+ case ']':
+ depth--
+ if depth == 0 {
+ return text[start : i+1]
+ }
+ }
+ }
+ }
+ return strings.TrimSpace(text[start:])
+}
+
+func opamScalar(value string) string {
+ values := opamTopLevelStrings(value)
+ if len(values) == 0 {
+ return ""
+ }
+ return values[0]
+}
+
+func opamTopLevelStrings(value string) []string {
+ var values []string
+ braceDepth := 0
+ inComment := false
+ for i := 0; i < len(value); {
+ switch value[i] {
+ case '#':
+ inComment = true
+ i++
+ case '\n':
+ inComment = false
+ i++
+ case '{':
+ if !inComment {
+ braceDepth++
+ }
+ i++
+ case '}':
+ if !inComment && braceDepth > 0 {
+ braceDepth--
+ }
+ i++
+ case '"':
+ if inComment {
+ i++
+ continue
+ }
+ start := i + 1
+ i++
+ escaped := false
+ var valueBuilder strings.Builder
+ for i < len(value) {
+ if escaped {
+ valueBuilder.WriteByte(value[i])
+ escaped = false
+ } else if value[i] == '\\' {
+ escaped = true
+ } else if value[i] == '"' {
+ break
+ } else {
+ valueBuilder.WriteByte(value[i])
+ }
+ i++
+ }
+ if braceDepth == 0 && i <= len(value) {
+ if valueBuilder.Len() == 0 {
+ values = append(values, value[start:i])
+ } else {
+ values = append(values, valueBuilder.String())
+ }
+ }
+ if i < len(value) {
+ i++
+ }
+ default:
+ i++
+ }
+ }
+ return values
+}
diff --git a/internal/opam/opam_test.go b/internal/opam/opam_test.go
new file mode 100644
index 0000000..dfbeae2
--- /dev/null
+++ b/internal/opam/opam_test.go
@@ -0,0 +1,55 @@
+package opam
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/git-pkgs/manifests/internal/core"
+)
+
+func TestParse(t *testing.T) {
+ content := []byte(`opam-version: "2.0"
+name: "example"
+version: "1.2.3"
+license: ["MIT" "ISC"]
+depends: [
+ "ocaml" {>= "5.0"}
+ # "commented-out"
+ "dune" {build}
+]
+`)
+
+ result, err := (&parser{}).Parse("example.opam", content)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ if result.Name != "example" {
+ t.Errorf("Name = %q, want example", result.Name)
+ }
+ if result.Version != "1.2.3" {
+ t.Errorf("Version = %q, want 1.2.3", result.Version)
+ }
+ if !slices.Equal(result.Licenses, []string{"MIT", "ISC"}) {
+ t.Errorf("Licenses = %q, want [MIT ISC]", result.Licenses)
+ }
+ if len(result.Dependencies) != 2 {
+ t.Fatalf("Dependencies = %d, want 2", len(result.Dependencies))
+ }
+ want := []core.Dependency{
+ {Name: "ocaml", Scope: core.Runtime, Direct: true},
+ {Name: "dune", Scope: core.Runtime, Direct: true},
+ }
+ if !slices.Equal(result.Dependencies, want) {
+ t.Errorf("Dependencies = %#v, want %#v", result.Dependencies, want)
+ }
+}
+
+func TestScalarLicense(t *testing.T) {
+ result, err := (&parser{}).Parse("opam", []byte(`license: "Apache-2.0"`))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ if !slices.Equal(result.Licenses, []string{"Apache-2.0"}) {
+ t.Errorf("Licenses = %q, want [Apache-2.0]", result.Licenses)
+ }
+}
diff --git a/internal/pypi/pypi.go b/internal/pypi/pypi.go
index b4ef00f..b80fae2 100644
--- a/internal/pypi/pypi.go
+++ b/internal/pypi/pypi.go
@@ -51,6 +51,7 @@ func init() {
// setup.py - manifest
core.Register("pypi", core.Manifest, &setupPyParser{}, core.ExactMatch("setup.py"))
+ core.Register("pypi", core.Manifest, &setupCfgParser{}, core.ExactMatch("setup.cfg"))
// pylock.toml - lockfile (PEP 665)
core.Register("pypi", core.Lockfile, &pylockTomlParser{}, core.ExactMatch("pylock.toml"))
@@ -262,6 +263,7 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result,
Poetry struct {
Name string `toml:"name"`
Version string `toml:"version"`
+ License string `toml:"license"`
Dependencies map[string]any `toml:"dependencies"`
DevDependencies map[string]any `toml:"dev-dependencies"`
Group map[string]struct {
@@ -272,6 +274,9 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result,
Project struct {
Name string `toml:"name"`
Version string `toml:"version"`
+ License any `toml:"license"`
+ LicenseFiles []string `toml:"license-files"`
+ Classifiers []string `toml:"classifiers"`
Dependencies []string `toml:"dependencies"`
OptionalDependencies map[string][]string `toml:"optional-dependencies"`
} `toml:"project"`
@@ -365,7 +370,51 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result,
selfVersion = pyproject.Tool.Poetry.Version
}
- return &core.Result{Name: selfName, Version: selfVersion, Dependencies: deps}, nil
+ licenses, licenseFile := pyprojectLicenses(pyproject.Project.License, pyproject.Project.LicenseFiles, pyproject.Project.Classifiers)
+ if pyproject.Project.Name == "" && pyproject.Tool.Poetry.License != "" {
+ licenses = []string{pyproject.Tool.Poetry.License}
+ }
+
+ return &core.Result{
+ Name: selfName,
+ Version: selfVersion,
+ Licenses: licenses,
+ LicenseFile: licenseFile,
+ Dependencies: deps,
+ }, nil
+}
+
+func pyprojectLicenses(license any, licenseFiles, classifiers []string) ([]string, string) {
+ var licenses []string
+ var licenseFile string
+ switch value := license.(type) {
+ case string:
+ if value != "" {
+ licenses = append(licenses, value)
+ }
+ case map[string]any:
+ if text, ok := value["text"].(string); ok && text != "" {
+ licenses = append(licenses, text)
+ }
+ if file, ok := value["file"].(string); ok {
+ licenseFile = file
+ }
+ }
+ licenses = append(licenses, licenseClassifiers(classifiers)...)
+ if licenseFile == "" && len(licenseFiles) > 0 {
+ licenseFile = licenseFiles[0]
+ }
+ return licenses, licenseFile
+}
+
+func licenseClassifiers(classifiers []string) []string {
+ var licenses []string
+ for _, classifier := range classifiers {
+ if strings.HasPrefix(classifier, "License ::") {
+ licenses = append(licenses, classifier)
+ }
+ }
+ return licenses
}
func extractPoetryVersion(value any) string {
@@ -694,6 +743,11 @@ var (
setupNameRegex = regexp.MustCompile(`\bname\s*=\s*['"]([^'"]+)['"]`)
// Match version= keyword argument in setup()
setupVersionRegex = regexp.MustCompile(`\bversion\s*=\s*['"]([^'"]+)['"]`)
+ // Match license= keyword argument in setup()
+ setupLicenseRegex = regexp.MustCompile(`\blicense\s*=\s*['"]([^'"]+)['"]`)
+ // Match classifiers= and license_files= list arguments in setup()
+ setupClassifiersRegex = regexp.MustCompile(`(?s)\bclassifiers\s*=\s*\[([^\]]*)\]`)
+ setupLicenseFilesRegex = regexp.MustCompile(`(?s)\blicense_files\s*=\s*\[([^\]]*)\]`)
)
func (p *setupPyParser) Parse(filename string, content []byte) (*core.Result, error) {
@@ -707,6 +761,20 @@ func (p *setupPyParser) Parse(filename string, content []byte) (*core.Result, er
if m := setupVersionRegex.FindStringSubmatch(contentStr); m != nil {
selfVersion = m[1]
}
+ var licenses []string
+ if m := setupLicenseRegex.FindStringSubmatch(contentStr); m != nil {
+ licenses = append(licenses, m[1])
+ }
+ if m := setupClassifiersRegex.FindStringSubmatch(contentStr); m != nil {
+ licenses = append(licenses, licenseClassifiers(quotedStrings(m[1]))...)
+ }
+ var licenseFile string
+ if m := setupLicenseFilesRegex.FindStringSubmatch(contentStr); m != nil {
+ files := quotedStrings(m[1])
+ if len(files) > 0 {
+ licenseFile = files[0]
+ }
+ }
// Parse install_requires
const regexCaptureGroups = 2 // full match + first capture group
@@ -740,7 +808,141 @@ func (p *setupPyParser) Parse(filename string, content []byte) (*core.Result, er
}
}
- return &core.Result{Name: selfName, Version: selfVersion, Dependencies: deps}, nil
+ return &core.Result{
+ Name: selfName,
+ Version: selfVersion,
+ Licenses: licenses,
+ LicenseFile: licenseFile,
+ Dependencies: deps,
+ }, nil
+}
+
+func quotedStrings(value string) []string {
+ var values []string
+ for _, match := range quotedStringRegex.FindAllStringSubmatch(value, -1) {
+ if len(match) > 1 {
+ values = append(values, match[1])
+ }
+ }
+ return values
+}
+
+// setupCfgParser parses setup.cfg files.
+type setupCfgParser struct{}
+
+func (p *setupCfgParser) Parse(_ string, content []byte) (*core.Result, error) {
+ sections := parseSetupCfgSections(string(content))
+ metadata := sections["metadata"]
+ options := sections["options"]
+
+ var licenses []string
+ if license := strings.TrimSpace(metadata["license"]); license != "" {
+ licenses = []string{license}
+ }
+ licenses = append(licenses, licenseClassifiers(configLines(metadata["classifiers"]))...)
+
+ licenseFile := strings.TrimSpace(metadata["license_file"])
+ if licenseFile == "" {
+ files := configCommaValues(metadata["license_files"])
+ if len(files) > 0 {
+ licenseFile = files[0]
+ }
+ }
+
+ var deps []core.Dependency
+ for _, requirement := range configLines(options["install_requires"]) {
+ name, version := parseSetupRequirement(requirement)
+ if name != "" {
+ deps = append(deps, core.Dependency{
+ Name: name,
+ Version: version,
+ Scope: core.Runtime,
+ Direct: true,
+ })
+ }
+ }
+ for group, value := range sections["options.extras_require"] {
+ for _, requirement := range configLines(value) {
+ name, version := parseSetupRequirement(requirement)
+ if name != "" {
+ deps = append(deps, core.Dependency{
+ Name: name,
+ Version: version,
+ Scope: optionalGroupScope(group),
+ Direct: true,
+ })
+ }
+ }
+ }
+
+ return &core.Result{
+ Name: strings.TrimSpace(metadata["name"]),
+ Version: strings.TrimSpace(metadata["version"]),
+ Licenses: licenses,
+ LicenseFile: licenseFile,
+ Dependencies: deps,
+ }, nil
+}
+
+func parseSetupCfgSections(content string) map[string]map[string]string {
+ sections := make(map[string]map[string]string)
+ var section, key string
+ for _, line := range strings.Split(content, "\n") {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") {
+ continue
+ }
+ if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
+ section = strings.ToLower(strings.TrimSpace(trimmed[1 : len(trimmed)-1]))
+ if sections[section] == nil {
+ sections[section] = make(map[string]string)
+ }
+ key = ""
+ continue
+ }
+ if section == "" {
+ continue
+ }
+ if line[0] == ' ' || line[0] == '\t' {
+ if key != "" {
+ sections[section][key] += "\n" + trimmed
+ }
+ continue
+ }
+ separator := strings.IndexAny(line, "=:")
+ if separator < 0 {
+ continue
+ }
+ key = strings.ToLower(strings.TrimSpace(line[:separator]))
+ sections[section][key] = strings.TrimSpace(line[separator+1:])
+ }
+ return sections
+}
+
+func configLines(value string) []string {
+ var values []string
+ for _, line := range strings.Split(value, "\n") {
+ line = strings.TrimSpace(line)
+ if idx := strings.Index(line, " #"); idx >= 0 {
+ line = strings.TrimSpace(line[:idx])
+ }
+ if line != "" {
+ values = append(values, line)
+ }
+ }
+ return values
+}
+
+func configCommaValues(value string) []string {
+ var values []string
+ for _, line := range configLines(value) {
+ for item := range strings.SplitSeq(line, ",") {
+ if item = strings.TrimSpace(item); item != "" {
+ values = append(values, item)
+ }
+ }
+ }
+ return values
}
func parseSetupRequirement(req string) (string, string) {
diff --git a/internal/pypi/pypi_test.go b/internal/pypi/pypi_test.go
index 03ee489..32d8469 100644
--- a/internal/pypi/pypi_test.go
+++ b/internal/pypi/pypi_test.go
@@ -567,6 +567,53 @@ func TestSetupPy(t *testing.T) {
}
}
+func TestSetupCfg(t *testing.T) {
+ content := []byte(`[metadata]
+name = example
+version = 1.2.3
+license = GPL (>= 2, < 3)
+license_files =
+ LICENSE
+classifiers =
+ Development Status :: 5 - Production/Stable
+ License :: OSI Approved :: MIT License
+
+[options]
+install_requires =
+ requests>=2,<3
+
+[options.extras_require]
+test =
+ pytest>=8
+`)
+
+ result, err := (&setupCfgParser{}).Parse("setup.cfg", content)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ if result.Name != "example" || result.Version != "1.2.3" {
+ t.Errorf("identity = %q %q, want example 1.2.3", result.Name, result.Version)
+ }
+ if len(result.Licenses) != 2 || result.Licenses[0] != "GPL (>= 2, < 3)" ||
+ result.Licenses[1] != "License :: OSI Approved :: MIT License" {
+ t.Errorf("Licenses = %q", result.Licenses)
+ }
+ if result.LicenseFile != "LICENSE" {
+ t.Errorf("LicenseFile = %q, want LICENSE", result.LicenseFile)
+ }
+ if len(result.Dependencies) != 2 {
+ t.Fatalf("Dependencies = %d, want 2", len(result.Dependencies))
+ }
+ if result.Dependencies[0].Name != "requests" ||
+ result.Dependencies[0].Version != ">=2,<3" ||
+ result.Dependencies[0].Scope != core.Runtime {
+ t.Errorf("runtime dependency = %#v", result.Dependencies[0])
+ }
+ if result.Dependencies[1].Name != "pytest" || result.Dependencies[1].Scope != core.Test {
+ t.Errorf("test dependency = %#v", result.Dependencies[1])
+ }
+}
+
func TestPylockToml(t *testing.T) {
content, err := os.ReadFile("../../testdata/pypi/pylock.toml")
if err != nil {
diff --git a/manifests.go b/manifests.go
index 2048eec..56cb1ad 100644
--- a/manifests.go
+++ b/manifests.go
@@ -50,7 +50,14 @@ type ParseResult struct {
Name string
// Version is the package's own version as declared in the
// manifest, when present.
- Version string
+ Version string
+ // Licenses holds the package's declared license values, verbatim as
+ // written in the manifest. Empty when the format has no license field
+ // or none is set. Scalar formats produce a single-element slice.
+ Licenses []string
+ // LicenseFile is a manifest-relative path to a license file when the
+ // format declares one instead of, or as well as, an expression.
+ LicenseFile string
Dependencies []Dependency
}
@@ -105,6 +112,8 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro
Kind: kind,
Name: res.Name,
Version: res.Version,
+ Licenses: res.Licenses,
+ LicenseFile: res.LicenseFile,
Dependencies: res.Dependencies,
}, nil
}
diff --git a/manifests_test.go b/manifests_test.go
index 63eee5a..89c1f28 100644
--- a/manifests_test.go
+++ b/manifests_test.go
@@ -3,6 +3,7 @@ package manifests
import (
"os"
"path/filepath"
+ "slices"
"strings"
"testing"
@@ -57,6 +58,192 @@ func TestParseAllEcosystems(t *testing.T) {
}
}
+func TestParseDeclaredLicenses(t *testing.T) {
+ testCases := []struct {
+ name string
+ filename string
+ content string
+ licenses []string
+ licenseFile string
+ }{
+ {
+ name: "npm scalar",
+ filename: "package.json",
+ content: `{"name":"example","version":"1.0.0","license":"MIT"}`,
+ licenses: []string{"MIT"},
+ },
+ {
+ name: "npm legacy object and array",
+ filename: "package.json",
+ content: `{"license":{"type":"ISC","url":"https://example.test/ISC"},"licenses":[{"type":"MIT"},{"type":"Apache-2.0"}]}`,
+ licenses: []string{"ISC", "MIT", "Apache-2.0"},
+ },
+ {
+ name: "cargo",
+ filename: "Cargo.toml",
+ content: "[package]\nname = \"example\"\nversion = \"1.0.0\"\nlicense = \"MIT OR Apache-2.0\"\nlicense-file = \"LICENSE.md\"\n",
+ licenses: []string{"MIT OR Apache-2.0"},
+ licenseFile: "LICENSE.md",
+ },
+ {
+ name: "pyproject PEP 639",
+ filename: "pyproject.toml",
+ content: "[project]\nname = \"example\"\nversion = \"1.0.0\"\nlicense = \"MIT\"\nlicense-files = [\"LICENSE\", \"NOTICE\"]\n",
+ licenses: []string{"MIT"},
+ licenseFile: "LICENSE",
+ },
+ {
+ name: "pyproject legacy metadata",
+ filename: "pyproject.toml",
+ content: `[project]
+name = "example"
+version = "1.0.0"
+license = {text = "BSD-3-Clause"}
+classifiers = ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: BSD License"]
+`,
+ licenses: []string{"BSD-3-Clause", "License :: OSI Approved :: BSD License"},
+ },
+ {
+ name: "setup cfg",
+ filename: "setup.cfg",
+ content: "[metadata]\nname = example\nversion = 1.0.0\nlicense = MIT\nlicense_files =\n LICENSE\n NOTICE\nclassifiers =\n License :: OSI Approved :: MIT License\n[options]\ninstall_requires =\n requests>=2\n",
+ licenses: []string{"MIT", "License :: OSI Approved :: MIT License"},
+ licenseFile: "LICENSE",
+ },
+ {
+ name: "setup py",
+ filename: "setup.py",
+ content: `setup(
+ name="example",
+ version="1.0.0",
+ license="MIT",
+ classifiers=["License :: OSI Approved :: MIT License"],
+ license_files=["LICENSE", "NOTICE"],
+)`,
+ licenses: []string{"MIT", "License :: OSI Approved :: MIT License"},
+ licenseFile: "LICENSE",
+ },
+ {
+ name: "gemspec array",
+ filename: "example.gemspec",
+ content: `Gem::Specification.new { |s| s.name = "example"; s.version = "1.0.0"; s.licenses = ["MIT", "Apache-2.0"] }`,
+ licenses: []string{"MIT", "Apache-2.0"},
+ },
+ {
+ name: "podspec hash",
+ filename: "example.podspec",
+ content: `Pod::Spec.new { |s| s.name = "Example"; s.version = "1.0"; s.license = { :type => "MIT", :file => "LICENSE" } }`,
+ licenses: []string{"MIT"},
+ licenseFile: "LICENSE",
+ },
+ {
+ name: "composer array",
+ filename: "composer.json",
+ content: `{"name":"example/package","version":"1.0.0","license":["MIT","Apache-2.0"]}`,
+ licenses: []string{"MIT", "Apache-2.0"},
+ },
+ {
+ name: "maven",
+ filename: "pom.xml",
+ content: `
+ 4.0.0
+ org.example
+ example
+ 1.0.0
+
+ MITLICENSE.txt
+ Apache-2.0https://www.apache.org/licenses/LICENSE-2.0.txt
+
+`,
+ licenses: []string{"MIT", "Apache-2.0"},
+ licenseFile: "LICENSE.txt",
+ },
+ {
+ name: "nuspec expression",
+ filename: "example.nuspec",
+ content: `Example1.0.0MIT OR Apache-2.0`,
+ licenses: []string{"MIT OR Apache-2.0"},
+ },
+ {
+ name: "nuspec file",
+ filename: "example.nuspec",
+ content: `Example1.0.0licenses/LICENSE.md`,
+ licenseFile: "licenses/LICENSE.md",
+ },
+ {
+ name: "cabal",
+ filename: "example.cabal",
+ content: "cabal-version: 3.0\nname: example\nversion: 1.0.0\nlicense: BSD-3-Clause\nlicense-files: LICENSE, NOTICE\n",
+ licenses: []string{"BSD-3-Clause"},
+ licenseFile: "LICENSE",
+ },
+ {
+ name: "R DESCRIPTION",
+ filename: "DESCRIPTION",
+ content: "Package: example\nVersion: 1.0.0\nLicense: GPL-3 | BSD_3_clause + file LICENSE\n",
+ licenses: []string{"GPL-3", "BSD_3_clause"},
+ licenseFile: "LICENSE",
+ },
+ {
+ name: "pub has no declaration",
+ filename: "pubspec.yaml",
+ content: "name: example\nversion: 1.0.0\n",
+ },
+ {
+ name: "go mod has no declaration",
+ filename: "go.mod",
+ content: "module example.com/project\n\ngo 1.25\n",
+ },
+ {
+ name: "mix package",
+ filename: "mix.exs",
+ content: `defmodule Example.MixProject do
+ use Mix.Project
+ def project do
+ [app: :example, version: "1.0.0", package: package()]
+ end
+ defp package do
+ [licenses: ["MIT", "Apache-2.0"]]
+ end
+end`,
+ licenses: []string{"MIT", "Apache-2.0"},
+ },
+ {
+ name: "opam list",
+ filename: "example.opam",
+ content: "opam-version: \"2.0\"\nname: \"example\"\nversion: \"1.0.0\"\nlicense: [\"MIT\" \"ISC\"]\ndepends: [\"ocaml\" {>= \"5.0\"} \"dune\"]\n",
+ licenses: []string{"MIT", "ISC"},
+ },
+ {
+ name: "elm",
+ filename: "elm.json",
+ content: `{"type":"package","name":"author/example","version":"1.0.0","license":"BSD-3-Clause","dependencies":{},"test-dependencies":{}}`,
+ licenses: []string{"BSD-3-Clause"},
+ },
+ {
+ name: "bower array",
+ filename: "bower.json",
+ content: `{"name":"example","version":"1.0.0","license":["MIT","Apache-2.0"]}`,
+ licenses: []string{"MIT", "Apache-2.0"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := Parse(tc.filename, []byte(tc.content))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ if !slices.Equal(result.Licenses, tc.licenses) {
+ t.Errorf("Licenses = %q, want %q", result.Licenses, tc.licenses)
+ }
+ if result.LicenseFile != tc.licenseFile {
+ t.Errorf("LicenseFile = %q, want %q", result.LicenseFile, tc.licenseFile)
+ }
+ })
+ }
+}
+
func TestParseName(t *testing.T) {
// One row per manifest format that declares its own package
// identity. Version is checked when the fixture has one; "" means