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