diff --git a/README.md b/README.md index f35f70d..8239640 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,7 @@ type ParseResult struct { Dependencies []Dependency Declarations []Declaration Sources []Source // Ordered manifest-level source declarations + Scripts map[string][]string // Hook or task names with ordered commands or paths } ``` @@ -339,6 +340,23 @@ type ParseResult struct { `Digest` contains a file-level verification value when the format defines one. For `Chart.lock`, it covers the dependency declarations from `Chart.yaml` and is separate from each dependency's `Integrity` value. +`Scripts` contains declared commands, callbacks or script paths, including named tasks as well as install hooks. For example, `result.Scripts["postinstall"]` holds the command from npm's `scripts.postinstall`. Lists preserve declaration order; empty declarations are omitted. + +| Format | Script declarations | +| --- | --- | +| `package.json`, `composer.json`, `shard.yml`, .NET `project.json` | Entries in `scripts` | +| `Cargo.toml` | `package.build`, under `build`; explicit `true` produces `build.rs`, `false` produces no entry | +| `.gemspec` | Literal `extensions` arrays, `%w` lists and `<<` additions | +| `.podspec` | Quoted or heredoc `prepare_command` | +| `deno.json` | Task strings and task objects' `command` values, under the task name | +| `dub.json` | Build, generate and run commands; platform suffixes are retained, with `configurations//`, `buildTypes//` and `subPackages//` prefixes for nested declarations | +| `.csproj`, `.vbproj`, `.fsproj` | `PreBuildEvent`, `PostBuildEvent` and direct `Exec` commands under `Target/` | +| `pyproject.toml` | Poetry's build script under `tool.poetry.build`, and PDM's explicit `tool.pdm.build.custom-hook` | +| OPAM | `build`, `install`, `remove`, `run-test`, `build-test` and `build-doc`, each as a raw field value retaining arguments and filters | +| `APKBUILD`, `PKGBUILD` | Unindented `install` assignments, under `install`, without shell expansion | + +Parsing does not execute scripts, evaluate conditions or follow script paths. Ruby string bodies retain source escapes and heredoc indentation. Dynamic Ruby expressions and arbitrary build code are not resolved. A missing entry does not establish that a package has no install hooks: files such as an undeclared `build.rs` or `deps/build.jl` require separate filesystem discovery. + Chef cookbook and Vagrant box PURLs remain empty while `chef` and `vagrant` are only candidate Package URL types without accepted name and namespace rules. diff --git a/internal/alpine/alpine.go b/internal/alpine/alpine.go index 35a00aa..feb4d24 100644 --- a/internal/alpine/alpine.go +++ b/internal/alpine/alpine.go @@ -57,7 +57,9 @@ func (p *apkbuildParser) Parse(filename string, content []byte) (*core.Result, e deps = append(deps, dep) } - return &core.Result{Name: vars["pkgname"], Version: vars["pkgver"], Dependencies: deps}, nil + var scripts map[string][]string + core.AddScript(&scripts, "install", strings.Fields(core.ShellInstall(string(content)))...) + return &core.Result{Name: vars["pkgname"], Version: vars["pkgver"], Dependencies: deps, Scripts: scripts}, nil } func parseApkbuildVars(content string) map[string]string { diff --git a/internal/arch/arch.go b/internal/arch/arch.go index d319dcd..c506671 100644 --- a/internal/arch/arch.go +++ b/internal/arch/arch.go @@ -55,7 +55,9 @@ func (p *pkgbuildParser) Parse(filename string, content []byte) (*core.Result, e deps = append(deps, dep) } - return &core.Result{Name: vars["pkgname"], Version: vars["pkgver"], Dependencies: deps}, nil + var scripts map[string][]string + core.AddScript(&scripts, "install", core.ShellInstall(string(content))) + return &core.Result{Name: vars["pkgname"], Version: vars["pkgver"], Dependencies: deps, Scripts: scripts}, nil } func parsePkgbuildVars(content string) map[string]string { diff --git a/internal/cargo/cargo.go b/internal/cargo/cargo.go index 4738d07..4b9bf20 100644 --- a/internal/cargo/cargo.go +++ b/internal/cargo/cargo.go @@ -39,6 +39,7 @@ func (p *cargoTomlParser) Parse(filename string, content []byte) (*core.Result, Version inheritableString `toml:"version"` License inheritableString `toml:"license"` LicenseFile inheritableString `toml:"license-file"` + Build any `toml:"build"` } `toml:"package"` Dependencies map[string]any `toml:"dependencies"` DevDependencies map[string]any `toml:"dev-dependencies"` @@ -87,11 +88,21 @@ func (p *cargoTomlParser) Parse(filename string, content []byte) (*core.Result, if cargo.Package.License != "" { licenses = []string{string(cargo.Package.License)} } + var scripts map[string][]string + switch build := cargo.Package.Build.(type) { + case string: + core.AddScript(&scripts, "build", build) + case bool: + if build { + core.AddScript(&scripts, "build", "build.rs") + } + } return &core.Result{ Name: pkgName, Version: string(cargo.Package.Version), Licenses: licenses, LicenseFile: string(cargo.Package.LicenseFile), + Scripts: scripts, Dependencies: filtered, Declarations: declarations, }, nil diff --git a/internal/cocoapods/cocoapods.go b/internal/cocoapods/cocoapods.go index aa698e7..2fce6d7 100644 --- a/internal/cocoapods/cocoapods.go +++ b/internal/cocoapods/cocoapods.go @@ -176,8 +176,40 @@ var ( podspecLicenseHashRegex = regexp.MustCompile(`(?s)\.license\s*=\s*\{([^}]*)\}`) podspecLicenseTypeRegex = regexp.MustCompile(`(?::type|["']type["']|\btype)\s*(?:=>|:)\s*["']([^"']+)["']`) podspecLicenseFileRegex = regexp.MustCompile(`(?::file|["']file["']|\bfile)\s*(?:=>|:)\s*["']([^"']+)["']`) + podspecPrepareRegex = regexp.MustCompile(`(?m)^[\t ]*\w+\.prepare_command[\t ]*=[\t ]*(?:("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|<<[-~]?["']?(\w+)["']?)[\t ]*(?:#.*)?\r?$`) ) +func podspecScripts(text string) map[string][]string { + var scripts map[string][]string + for { + match := podspecPrepareRegex.FindStringSubmatchIndex(text) + if match == nil { + break + } + literal := "" + if match[2] >= 0 { + literal = text[match[2]:match[3]] + } + delimiter := "" + if match[4] >= 0 { + delimiter = text[match[4]:match[5]] + } + text = text[match[1]:] + if literal != "" { + core.AddScript(&scripts, "prepare_command", literal[1:len(literal)-1]) + continue + } + text = strings.TrimPrefix(text, "\n") + end := regexp.MustCompile(`(?m)^[\t ]*` + regexp.QuoteMeta(delimiter) + `[\t ]*\r?$`).FindStringIndex(text) + if end == nil { + break + } + core.AddScript(&scripts, "prepare_command", text[:end[0]]) + text = text[end[1]:] + } + return scripts +} + func (p *podspecParser) Parse(filename string, content []byte) (*core.Result, error) { var deps []core.Dependency text := string(content) @@ -222,6 +254,7 @@ func (p *podspecParser) Parse(filename string, content []byte) (*core.Result, er Version: selfVersion, Licenses: licenses, LicenseFile: licenseFile, + Scripts: podspecScripts(text), Dependencies: deps, }, nil } diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 7fc3368..dda506f 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -17,6 +17,7 @@ type composerJSON struct { Name string `json:"name"` Version string `json:"version"` License any `json:"license"` + Scripts map[string]any `json:"scripts"` Require map[string]string `json:"require"` RequireDev map[string]string `json:"require-dev"` } @@ -64,6 +65,7 @@ func (p *composerJSONParser) Parse(filename string, content []byte) (*core.Resul Name: composer.Name, Version: composer.Version, Licenses: composerLicenses(composer.License), + Scripts: core.StringScripts(composer.Scripts), Dependencies: deps, }, nil } diff --git a/internal/core/scripts.go b/internal/core/scripts.go new file mode 100644 index 0000000..3e1076e --- /dev/null +++ b/internal/core/scripts.go @@ -0,0 +1,32 @@ +package core + +// StringScripts accepts string commands and ordered lists of string commands. +func StringScripts(values map[string]any) map[string][]string { + var scripts map[string][]string + for name, value := range values { + switch commands := value.(type) { + case string: + AddScript(&scripts, name, commands) + case []any: + for _, command := range commands { + if text, ok := command.(string); ok { + AddScript(&scripts, name, text) + } + } + } + } + return scripts +} + +// AddScript preserves command order and omits empty declarations. +func AddScript(scripts *map[string][]string, name string, commands ...string) { + for _, command := range commands { + if command == "" { + continue + } + if *scripts == nil { + *scripts = make(map[string][]string) + } + (*scripts)[name] = append((*scripts)[name], command) + } +} diff --git a/internal/core/shell.go b/internal/core/shell.go new file mode 100644 index 0000000..a2b0dd3 --- /dev/null +++ b/internal/core/shell.go @@ -0,0 +1,19 @@ +package core + +import "regexp" + +var shellInstallPattern = regexp.MustCompile(`(?m)^install=(?:"([^"\\]*)"|'([^']*)'|([^\s#;'"\\]+))[\t ]*(?:#.*)?\r?$`) + +// ShellInstall extracts a top-level install assignment without shell expansion. +func ShellInstall(content string) string { + match := shellInstallPattern.FindStringSubmatch(content) + if match == nil { + return "" + } + for _, value := range match[1:] { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/core/types.go b/internal/core/types.go index c2e9cd5..815d0ac 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -103,6 +103,8 @@ type Result struct { // These entries are configuration, not evidence that any dependency was // resolved from a particular source. Sources []Source + // Scripts maps declared hook or task names to ordered commands or script paths. + Scripts map[string][]string } // Parser is the interface implemented by all manifest parsers. diff --git a/internal/crystal/crystal.go b/internal/crystal/crystal.go index 648bdae..af79445 100644 --- a/internal/crystal/crystal.go +++ b/internal/crystal/crystal.go @@ -37,6 +37,7 @@ type shardYMLParser struct{} type shardYML struct { Name string `yaml:"name"` Version string `yaml:"version"` + Scripts map[string]any `yaml:"scripts"` Dependencies map[string]shardDep `yaml:"dependencies"` DevelopmentDependencies map[string]shardDep `yaml:"development_dependencies"` } @@ -78,7 +79,7 @@ func (p *shardYMLParser) Parse(filename string, content []byte) (*core.Result, e }) } - return &core.Result{Name: shard.Name, Version: shard.Version, Dependencies: deps}, nil + return &core.Result{Name: shard.Name, Version: shard.Version, Dependencies: deps, Scripts: core.StringScripts(shard.Scripts)}, nil } func getShardVersion(dep shardDep) string { diff --git a/internal/dub/dub.go b/internal/dub/dub.go index c3cdfb4..da75036 100644 --- a/internal/dub/dub.go +++ b/internal/dub/dub.go @@ -3,7 +3,9 @@ package dub import ( "encoding/json" "github.com/git-pkgs/manifests/internal/core" + "net/url" "regexp" + "strconv" "strings" ) @@ -53,7 +55,50 @@ func (p *dubJSONParser) Parse(filename string, content []byte) (*core.Result, er }) } - return &core.Result{Name: dub.Name, Version: dub.Version, Dependencies: deps}, nil + var recipe map[string]any + if err := json.Unmarshal(content, &recipe); err != nil { + return nil, &core.ParseError{Filename: filename, Err: err} + } + var scripts map[string][]string + collectDubScripts(&scripts, "", recipe) + return &core.Result{Name: dub.Name, Version: dub.Version, Dependencies: deps, Scripts: scripts}, nil +} + +func collectDubScripts(scripts *map[string][]string, prefix string, recipe map[string]any) { + for name, value := range recipe { + hook, _, _ := strings.Cut(name, "-") + if isDubHook(hook) { + for _, commands := range core.StringScripts(map[string]any{name: value}) { + core.AddScript(scripts, prefix+name, commands...) + } + } + } + for _, group := range []string{"configurations", "subPackages"} { + entries, _ := recipe[group].([]any) + for i, entry := range entries { + if properties, ok := entry.(map[string]any); ok { + name, _ := properties["name"].(string) + if name == "" { + name = strconv.Itoa(i) + } + collectDubScripts(scripts, prefix+group+"/"+url.PathEscape(name)+"/", properties) + } + } + } + buildTypes, _ := recipe["buildTypes"].(map[string]any) + for name, entry := range buildTypes { + if properties, ok := entry.(map[string]any); ok { + collectDubScripts(scripts, prefix+"buildTypes/"+url.PathEscape(name)+"/", properties) + } + } +} + +func isDubHook(name string) bool { + switch name { + case "preGenerateCommands", "postGenerateCommands", "preBuildCommands", "postBuildCommands", "preRunCommands", "postRunCommands": + return true + } + return false } // dubSDLParser parses dub.sdl files. diff --git a/internal/gem/rubygems.go b/internal/gem/rubygems.go index 0450396..a67d3ea 100644 --- a/internal/gem/rubygems.go +++ b/internal/gem/rubygems.go @@ -391,11 +391,30 @@ func (p *gemfileLockParser) Parse(filename string, content []byte) (*core.Result type gemspecParser struct{} var ( - gemspecLicenseRegex = regexp.MustCompile(`(?m)\.license\s*=\s*["']([^"']+)["']`) - gemspecLicensesRegex = regexp.MustCompile(`(?s)\.licenses\s*=\s*\[([^\]]*)\]`) - rubyQuotedRegex = regexp.MustCompile(`["']([^"']+)["']`) + gemspecLicenseRegex = regexp.MustCompile(`(?m)\.license\s*=\s*["']([^"']+)["']`) + gemspecLicensesRegex = regexp.MustCompile(`(?s)\.licenses\s*=\s*\[([^\]]*)\]`) + rubyQuotedRegex = regexp.MustCompile(`["']([^"']+)["']`) + gemspecExtensionsRegex = regexp.MustCompile(`(?m)^[\t ]*\w+\.extensions[\t ]*(?:=|\+=|<<)[\t ]*(\[(?:\s*` + rubyScriptLiteral + `\s*,?)*\s*\]|%w\[[^\]\\]*\]|%w\([^\)\\]*\)|` + rubyScriptLiteral + `)[\t ]*(?:#.*)?\r?$`) + gemspecScriptStringRegex = regexp.MustCompile(rubyScriptLiteral) ) +const rubyScriptLiteral = `(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')` + +func gemspecScripts(text string) map[string][]string { + var scripts map[string][]string + for _, match := range gemspecExtensionsRegex.FindAllStringSubmatch(text, -1) { + value := match[1] + if strings.HasPrefix(value, "%w") { + core.AddScript(&scripts, "extensions", strings.Fields(value[len("%w["):len(value)-1])...) + continue + } + for _, literal := range gemspecScriptStringRegex.FindAllString(value, -1) { + core.AddScript(&scripts, "extensions", literal[1:len(literal)-1]) + } + } + return scripts +} + // 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). @@ -521,6 +540,7 @@ func (p *gemspecParser) Parse(filename string, content []byte) (*core.Result, er Name: selfName, Version: selfVersion, Licenses: licenses, + Scripts: gemspecScripts(text), Dependencies: deps, }, nil } diff --git a/internal/npm/deno.go b/internal/npm/deno.go index 2e8620c..a351bcc 100644 --- a/internal/npm/deno.go +++ b/internal/npm/deno.go @@ -19,6 +19,7 @@ type denoJSON struct { Name string `json:"name"` Version string `json:"version"` Imports map[string]string `json:"imports"` + Tasks map[string]any `json:"tasks"` } func (p *denoJSONParser) Parse(filename string, content []byte) (*core.Result, error) { @@ -41,7 +42,15 @@ func (p *denoJSONParser) Parse(filename string, content []byte) (*core.Result, e } } - return &core.Result{Name: deno.Name, Version: deno.Version, Dependencies: deps}, nil + scripts := core.StringScripts(deno.Tasks) + for name, task := range deno.Tasks { + if properties, ok := task.(map[string]any); ok { + if command, ok := properties["command"].(string); ok { + core.AddScript(&scripts, name, command) + } + } + } + return &core.Result{Name: deno.Name, Version: deno.Version, Dependencies: deps, Scripts: scripts}, nil } // denoLockParser parses deno.lock files. diff --git a/internal/npm/npm.go b/internal/npm/npm.go index 658c2c0..0395fb1 100644 --- a/internal/npm/npm.go +++ b/internal/npm/npm.go @@ -29,6 +29,7 @@ type packageJSON struct { Version string `json:"version"` License any `json:"license"` Licenses []npmLicense `json:"licenses"` + Scripts map[string]any `json:"scripts"` Dependencies map[string]any `json:"dependencies"` DevDependencies map[string]any `json:"devDependencies"` OptionalDependencies map[string]any `json:"optionalDependencies"` @@ -56,6 +57,7 @@ func (p *npmPackageJSONParser) Parse(filename string, content []byte) (*core.Res Name: pkg.Name, Version: pkg.Version, Licenses: npmLicenses(pkg.License, pkg.Licenses), + Scripts: core.StringScripts(pkg.Scripts), Dependencies: deps, Declarations: declarations, }, nil diff --git a/internal/nuget/nuget.go b/internal/nuget/nuget.go index 0d82d56..94cf27b 100644 --- a/internal/nuget/nuget.go +++ b/internal/nuget/nuget.go @@ -38,12 +38,22 @@ type csprojParser struct{} type csprojProject struct { PropertyGroups []csprojPropertyGroup `xml:"PropertyGroup"` ItemGroups []csprojItemGroup `xml:"ItemGroup"` + Targets []csprojTarget `xml:"Target"` +} + +type csprojTarget struct { + Name string `xml:"Name,attr"` + Exec []struct { + Command string `xml:"Command,attr"` + } `xml:"Exec"` } type csprojPropertyGroup struct { - AssemblyName string `xml:"AssemblyName"` - PackageID string `xml:"PackageId"` - Version string `xml:"Version"` + AssemblyName string `xml:"AssemblyName"` + PackageID string `xml:"PackageId"` + Version string `xml:"Version"` + PreBuildEvent string `xml:"PreBuildEvent"` + PostBuildEvent string `xml:"PostBuildEvent"` } type csprojItemGroup struct { @@ -212,7 +222,10 @@ func (p *csprojParser) Parse(filename string, content []byte) (*core.Result, err base := filepath.Base(filename) selfName := strings.TrimSuffix(base, filepath.Ext(base)) var selfVersion string + var scripts map[string][]string for _, pg := range project.PropertyGroups { + core.AddScript(&scripts, "PreBuildEvent", pg.PreBuildEvent) + core.AddScript(&scripts, "PostBuildEvent", pg.PostBuildEvent) if pg.PackageID != "" { selfName = pg.PackageID } else if pg.AssemblyName != "" { @@ -222,10 +235,16 @@ func (p *csprojParser) Parse(filename string, content []byte) (*core.Result, err selfVersion = pg.Version } } + for _, target := range project.Targets { + for _, exec := range target.Exec { + core.AddScript(&scripts, "Target/"+url.PathEscape(target.Name), exec.Command) + } + } return &core.Result{ Name: selfName, Version: selfVersion, + Scripts: scripts, Dependencies: deps, Declarations: declarations, }, nil @@ -658,6 +677,7 @@ type projectJSONParser struct{} type projectJSON struct { Dependencies map[string]any `json:"dependencies"` + Scripts map[string]any `json:"scripts"` } func (p *projectJSONParser) Parse(filename string, content []byte) (*core.Result, error) { @@ -690,7 +710,7 @@ func (p *projectJSONParser) Parse(filename string, content []byte) (*core.Result appendNuGetDeclaration(&declarations, locations, "dependencies", name, version, core.Runtime) } - return &core.Result{Dependencies: deps, Declarations: declarations}, nil + return &core.Result{Dependencies: deps, Declarations: declarations, Scripts: core.StringScripts(proj.Scripts)}, nil } // libraryEntry holds the fields shared by deps.json and project.lock.json libraries. diff --git a/internal/opam/opam.go b/internal/opam/opam.go index 3c98821..ef65e82 100644 --- a/internal/opam/opam.go +++ b/internal/opam/opam.go @@ -26,10 +26,23 @@ func (p *parser) Parse(_ string, content []byte) (*core.Result, error) { Name: opamScalar(opamField(text, "name")), Version: opamScalar(opamField(text, "version")), Licenses: opamTopLevelStrings(opamField(text, "license")), + Scripts: opamScripts(text), Dependencies: opamDependencies(opamField(text, "depends")), }, nil } +func opamScripts(text string) map[string][]string { + var scripts map[string][]string + for _, field := range []string{"build", "install", "remove", "run-test", "build-test", "build-doc"} { + value := opamField(text, field) + if value != "[]" && strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "["), "]")) != "" { + // Keep argument boundaries, filters, and variable references intact. + core.AddScript(&scripts, field, value) + } + } + return scripts +} + func opamField(text, field string) string { pattern := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(field) + `[ \t]*:[ \t]*`) location := pattern.FindStringIndex(text) diff --git a/internal/pypi/pypi.go b/internal/pypi/pypi.go index 171599b..dd2bfda 100644 --- a/internal/pypi/pypi.go +++ b/internal/pypi/pypi.go @@ -272,12 +272,18 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result, Name string `toml:"name"` Version string `toml:"version"` License string `toml:"license"` + Build any `toml:"build"` Dependencies map[string]any `toml:"dependencies"` DevDependencies map[string]any `toml:"dev-dependencies"` Group map[string]struct { Dependencies map[string]any `toml:"dependencies"` } `toml:"group"` } `toml:"poetry"` + PDM struct { + Build struct { + CustomHook string `toml:"custom-hook"` + } `toml:"build"` + } `toml:"pdm"` } `toml:"tool"` Project struct { Name string `toml:"name"` @@ -399,12 +405,23 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result, if !projectDeclaresLicense && pyproject.Tool.Poetry.License != "" { licenses = []string{pyproject.Tool.Poetry.License} } + var scripts map[string][]string + switch build := pyproject.Tool.Poetry.Build.(type) { + case string: + core.AddScript(&scripts, "tool.poetry.build", build) + case map[string]any: + if script, ok := build["script"].(string); ok { + core.AddScript(&scripts, "tool.poetry.build", script) + } + } + core.AddScript(&scripts, "tool.pdm.build.custom-hook", pyproject.Tool.PDM.Build.CustomHook) return &core.Result{ Name: selfName, Version: selfVersion, Licenses: licenses, LicenseFile: licenseFile, + Scripts: scripts, Dependencies: deps, Declarations: declarations, }, nil diff --git a/manifests.go b/manifests.go index 65cd011..bb86b4b 100644 --- a/manifests.go +++ b/manifests.go @@ -94,6 +94,11 @@ type ParseResult struct { // Sources preserves manifest-level source declarations in source order. // A source declaration does not imply that any dependency resolved there. Sources []Source + // Scripts maps declared hook or task names to ordered commands, callbacks, + // or script paths. Keys and values are ecosystem-specific; declarations + // are not executed or resolved. An empty map does not rule out implicit + // hooks or code in formats the parser cannot statically inspect. + Scripts map[string][]string } // Options configures Parse. @@ -156,6 +161,7 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro Dependencies: res.Dependencies, Declarations: res.Declarations, Sources: res.Sources, + Scripts: res.Scripts, }, nil } diff --git a/scripts_test.go b/scripts_test.go new file mode 100644 index 0000000..c9efdc8 --- /dev/null +++ b/scripts_test.go @@ -0,0 +1,138 @@ +package manifests_test + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/git-pkgs/manifests" +) + +func TestParseScripts(t *testing.T) { + tests := []struct { + file string + dependency string + scripts map[string][]string + }{ + {"npm/package.json", "bindings", map[string][]string{ + "preinstall": {"node scripts/check.js"}, "install": {"node-gyp rebuild"}, + "postinstall": {"node scripts/setup.js"}, "test": {"node --test"}, + }}, + {"composer/composer.json", "example/library", map[string][]string{ + "post-install-cmd": {`Example\Installer::install`, "@php scripts/setup.php"}, + "post-update-cmd": {"@post-install-cmd"}, "test": {"php tests/run.php"}, + }}, + {"cargo/Cargo.toml", "libc", map[string][]string{"build": {"scripts/build.rs"}}}, + {"gem/native.gemspec", "rake", map[string][]string{"extensions": { + "ext/widget/extconf.rb", "ext/helper/extconf.rb", "ext/extra/extconf.rb", "ext/final/extconf.rb", + }}}, + {"crystal/shard.yml", "openssl", map[string][]string{"postinstall": {"make native\nmake copy\n"}}}, + {"deno/deno.json", "chalk", map[string][]string{"build": {"deno run -A build.ts"}, "test": {"deno test"}}}, + {"dub/dub.json", "vibe-d", map[string][]string{ + "preBuildCommands": {"echo preparing", "make native"}, + "postBuildCommands-windows": {"copy widget.dll bin"}, + "configurations/release/postBuildCommands": {"strip widget"}, + "buildTypes/coverage/preRunCommands": {"mkdir -p coverage"}, + "subPackages/helper/preGenerateCommands": {"generate helper"}, + }}, + {"nuget/project.json", "Example.Library", map[string][]string{ + "precompile": {"echo preparing", "generate-code"}, "postcompile": {"copy-assets"}, + }}, + {"nuget/example.csproj", "Example.Library", map[string][]string{ + "PreBuildEvent": {"echo preparing"}, "PostBuildEvent": {"echo complete"}, + "Target/Generate": {`echo "generating"`, "generate-code"}, + }}, + {"pypi/pyproject.toml", "cython", map[string][]string{"tool.poetry.build": {"scripts/build.py"}}}, + {"opam/widget.opam", "dune", map[string][]string{ + "build": {"[\n [\"dune\" \"build\" \"-p\" name \"-j\" jobs]\n [\"dune\" \"runtest\"] {with-test}\n]"}, + "install": {`[[make "install"]]`}, + }}, + {"alpine/APKBUILD", "musl", map[string][]string{"install": {"$pkgname.pre-install", "$pkgname.post-install"}}}, + {"arch/PKGBUILD", "glibc", map[string][]string{"install": {"widget.install"}}}, + {"cocoapods/Widget.podspec", "Helper", map[string][]string{"prepare_command": {" make native\n cp native.a lib/\n"}}}, + } + for _, tt := range tests { + t.Run(tt.file, func(t *testing.T) { + content, err := os.ReadFile(filepath.Join("testdata/scripts", tt.file)) + if err != nil { + t.Fatal(err) + } + result, err := manifests.Parse(filepath.Base(tt.file), content) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(result.Scripts, tt.scripts) { + t.Fatalf("Scripts = %#v, want %#v", result.Scripts, tt.scripts) + } + if len(result.Dependencies) != 1 || result.Dependencies[0].Name != tt.dependency { + t.Fatalf("Dependencies = %#v, want %s", result.Dependencies, tt.dependency) + } + }) + } +} + +func TestParseScriptVariants(t *testing.T) { + tests := []struct { + name, file, content string + scripts map[string][]string + }{ + {"cargo enabled", "Cargo.toml", "[package]\nbuild = true", map[string][]string{"build": {"build.rs"}}}, + {"cargo disabled", "Cargo.toml", "[package]\nbuild = false", nil}, + {"cargo implicit", "Cargo.toml", "[package]\nname = 'example'", nil}, + {"poetry shorthand", "pyproject.toml", "[tool.poetry]\nbuild = 'build.py'", map[string][]string{"tool.poetry.build": {"build.py"}}}, + {"pdm custom hook", "pyproject.toml", "[tool.pdm.build]\ncustom-hook = 'build.py'", map[string][]string{"tool.pdm.build.custom-hook": {"build.py"}}}, + {"pdm implicit hook", "pyproject.toml", "[build-system]\nbuild-backend = 'pdm.backend'", nil}, + {"gem words", "example.gemspec", "s.extensions = %w(ext/a.rb ext/b.rb)", map[string][]string{"extensions": {"ext/a.rb", "ext/b.rb"}}}, + {"gem dynamic", "example.gemspec", "s.extensions = Dir['ext/**/extconf.rb']", nil}, + {"gem crlf", "example.gemspec", "s.extensions = ['ext/a.rb']\r\n", map[string][]string{"extensions": {"ext/a.rb"}}}, + {"gem comment", "example.gemspec", "# s.extensions = ['ext/a.rb']", nil}, + {"podspec literal", "example.podspec", "s.prepare_command = 'make native'", map[string][]string{"prepare_command": {"make native"}}}, + {"podspec comment", "example.podspec", "# s.prepare_command = 'make native'", nil}, + {"podspec dynamic", "example.podspec", "s.prepare_command = File.read('prepare.sh')", nil}, + {"podspec quoted earlier occurrence", "example.podspec", "x = \"s.prepare_command = 'make native'\"\ns.prepare_command = 'make native'", map[string][]string{"prepare_command": {"make native"}}}, + {"podspec declaration order", "example.podspec", "s.prepare_command = <<-CMD\nmake first\nCMD\ns.prepare_command = 'make second'", map[string][]string{"prepare_command": {"make first\n", "make second"}}}, + {"podspec unterminated heredoc", "example.podspec", "s.prepare_command = <<-CMD\nmake first", nil}, + {"arch unquoted", "PKGBUILD", "install=$pkgname.install", map[string][]string{"install": {"$pkgname.install"}}}, + {"arch quoted", "PKGBUILD", "install=\"$pkgname.install\"", map[string][]string{"install": {"$pkgname.install"}}}, + {"alpine single quoted", "APKBUILD", "install='a.pre-install a.post-install'", map[string][]string{"install": {"a.pre-install", "a.post-install"}}}, + {"shell comment", "APKBUILD", "# install='a.pre-install'", nil}, + {"shell partial expression", "PKGBUILD", "install='a'\".install\"", nil}, + {"composer empty", "composer.json", `{"scripts":{"post-install-cmd":[],"test":null}}`, nil}, + {"npm metadata", "package.json", `{"scripts":{"test":"node --test","description":false}}`, map[string][]string{"test": {"node --test"}}}, + {"deno task graph", "deno.json", `{"tasks":{"all":{"dependencies":["build"]}}}`, nil}, + {"opam filters", "example.opam", "build: [[\"echo\" \"]\"] {os = \"linux\"}]\nremove: [ ]", map[string][]string{"build": {`[["echo" "]"] {os = "linux"}]`}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := manifests.Parse(tt.file, []byte(tt.content)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(result.Scripts, tt.scripts) { + t.Fatalf("Scripts = %#v, want %#v", result.Scripts, tt.scripts) + } + }) + } +} + +func TestParseWithoutScripts(t *testing.T) { + for _, file := range []string{ + "package.json", "composer.json", "deno.json", "dub.json", "project.json", "package-lock.json", "composer.lock", + "shard.yml", "Cargo.toml", "pyproject.toml", "example.gemspec", "example.podspec", "APKBUILD", "PKGBUILD", "example.opam", + } { + t.Run(file, func(t *testing.T) { + content := "" + if filepath.Ext(file) == ".json" || file == "composer.lock" { + content = "{}" + } + result, err := manifests.Parse(file, []byte(content)) + if err != nil { + t.Fatal(err) + } + if result.Scripts != nil { + t.Fatalf("Scripts = %#v, want nil", result.Scripts) + } + }) + } +} diff --git a/testdata/scripts/alpine/APKBUILD b/testdata/scripts/alpine/APKBUILD new file mode 100644 index 0000000..22c06b1 --- /dev/null +++ b/testdata/scripts/alpine/APKBUILD @@ -0,0 +1,5 @@ +pkgname=widget +pkgver=1.0 +depends="musl" +install="$pkgname.pre-install + $pkgname.post-install" diff --git a/testdata/scripts/arch/PKGBUILD b/testdata/scripts/arch/PKGBUILD new file mode 100644 index 0000000..6b6f002 --- /dev/null +++ b/testdata/scripts/arch/PKGBUILD @@ -0,0 +1,4 @@ +pkgname=widget +pkgver=1.0 +depends=('glibc') +install='widget.install' diff --git a/testdata/scripts/cargo/Cargo.toml b/testdata/scripts/cargo/Cargo.toml new file mode 100644 index 0000000..12ade1d --- /dev/null +++ b/testdata/scripts/cargo/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "native-widget" +build = "scripts/build.rs" + +[dependencies] +libc = "0.2" diff --git a/testdata/scripts/cocoapods/Widget.podspec b/testdata/scripts/cocoapods/Widget.podspec new file mode 100644 index 0000000..666dd87 --- /dev/null +++ b/testdata/scripts/cocoapods/Widget.podspec @@ -0,0 +1,8 @@ +Pod::Spec.new do |s| + s.name = 'Widget' + s.dependency 'Helper', '~> 1.0' + s.prepare_command = <<-CMD + make native + cp native.a lib/ + CMD +end diff --git a/testdata/scripts/composer/composer.json b/testdata/scripts/composer/composer.json new file mode 100644 index 0000000..3fa0cae --- /dev/null +++ b/testdata/scripts/composer/composer.json @@ -0,0 +1,9 @@ +{ + "name": "example/app", + "require": { "example/library": "^1.0" }, + "scripts": { + "post-install-cmd": ["Example\\Installer::install", "@php scripts/setup.php"], + "post-update-cmd": "@post-install-cmd", + "test": "php tests/run.php" + } +} diff --git a/testdata/scripts/crystal/shard.yml b/testdata/scripts/crystal/shard.yml new file mode 100644 index 0000000..0ebbb52 --- /dev/null +++ b/testdata/scripts/crystal/shard.yml @@ -0,0 +1,8 @@ +name: native-widget +dependencies: + openssl: + version: "~> 2.0" +scripts: + postinstall: | + make native + make copy diff --git a/testdata/scripts/deno/deno.json b/testdata/scripts/deno/deno.json new file mode 100644 index 0000000..cd56297 --- /dev/null +++ b/testdata/scripts/deno/deno.json @@ -0,0 +1,8 @@ +{ + "imports": { "chalk": "npm:chalk@^5.0" }, + "tasks": { + "build": "deno run -A build.ts", + "test": { "command": "deno test", "dependencies": ["build"] }, + "all": { "dependencies": ["build", "test"] } + } +} diff --git a/testdata/scripts/dub/dub.json b/testdata/scripts/dub/dub.json new file mode 100644 index 0000000..c05780c --- /dev/null +++ b/testdata/scripts/dub/dub.json @@ -0,0 +1,14 @@ +{ + "name": "native-widget", + "dependencies": { "vibe-d": "~>0.9" }, + "preBuildCommands": ["echo preparing", "make native"], + "postBuildCommands-windows": ["copy widget.dll bin"], + "configurations": [ + { "name": "release", "postBuildCommands": ["strip widget"] } + ], + "buildTypes": { "coverage": { "preRunCommands": ["mkdir -p coverage"] } }, + "subPackages": [ + { "name": "helper", "preGenerateCommands": ["generate helper"] }, + "external" + ] +} diff --git a/testdata/scripts/gem/native.gemspec b/testdata/scripts/gem/native.gemspec new file mode 100644 index 0000000..595080f --- /dev/null +++ b/testdata/scripts/gem/native.gemspec @@ -0,0 +1,11 @@ +Gem::Specification.new do |spec| + spec.name = "native-widget" + spec.extensions = [ + "ext/widget/extconf.rb", + 'ext/helper/extconf.rb', + ] + spec.extensions += %w[ext/extra/extconf.rb] + spec.extensions << 'ext/final/extconf.rb' + # spec.extensions << 'ext/comment/extconf.rb' + spec.add_dependency "rake", ">= 0" +end diff --git a/testdata/scripts/npm/package.json b/testdata/scripts/npm/package.json new file mode 100644 index 0000000..cfe3ee2 --- /dev/null +++ b/testdata/scripts/npm/package.json @@ -0,0 +1,11 @@ +{ + "name": "native-widget", + "scripts": { + "preinstall": "node scripts/check.js", + "install": "node-gyp rebuild", + "postinstall": "node scripts/setup.js", + "test": "node --test", + "empty": "" + }, + "dependencies": { "bindings": "^1.0.0" } +} diff --git a/testdata/scripts/nuget/example.csproj b/testdata/scripts/nuget/example.csproj new file mode 100644 index 0000000..79e5fda --- /dev/null +++ b/testdata/scripts/nuget/example.csproj @@ -0,0 +1,13 @@ + + + echo preparing + echo complete + + + + + + + + + diff --git a/testdata/scripts/nuget/project.json b/testdata/scripts/nuget/project.json new file mode 100644 index 0000000..9e0b923 --- /dev/null +++ b/testdata/scripts/nuget/project.json @@ -0,0 +1,7 @@ +{ + "dependencies": { "Example.Library": "1.0.0" }, + "scripts": { + "precompile": ["echo preparing", "generate-code"], + "postcompile": "copy-assets" + } +} diff --git a/testdata/scripts/opam/widget.opam b/testdata/scripts/opam/widget.opam new file mode 100644 index 0000000..4e00410 --- /dev/null +++ b/testdata/scripts/opam/widget.opam @@ -0,0 +1,9 @@ +opam-version: "2.0" +name: "widget" +depends: ["dune" {>= "3.0"}] +build: [ + ["dune" "build" "-p" name "-j" jobs] + ["dune" "runtest"] {with-test} +] +install: [[make "install"]] +remove: [] diff --git a/testdata/scripts/pypi/pyproject.toml b/testdata/scripts/pypi/pyproject.toml new file mode 100644 index 0000000..0e7888a --- /dev/null +++ b/testdata/scripts/pypi/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "native-widget" +dependencies = ["cython>=3"] + +[project.scripts] +widget = "widget:main" + +[tool.poetry.build] +script = "scripts/build.py"