diff --git a/imports.go b/imports.go index 9be0c28..ee7c661 100644 --- a/imports.go +++ b/imports.go @@ -28,6 +28,7 @@ import ( _ "github.com/git-pkgs/manifests/internal/hackage" _ "github.com/git-pkgs/manifests/internal/haxelib" _ "github.com/git-pkgs/manifests/internal/hex" + _ "github.com/git-pkgs/manifests/internal/ips" _ "github.com/git-pkgs/manifests/internal/julia" _ "github.com/git-pkgs/manifests/internal/luarocks" _ "github.com/git-pkgs/manifests/internal/maven" diff --git a/internal/ips/ips.go b/internal/ips/ips.go new file mode 100644 index 0000000..aeeddfd --- /dev/null +++ b/internal/ips/ips.go @@ -0,0 +1,146 @@ +package ips + +import ( + "strings" + + "github.com/git-pkgs/manifests/internal/core" +) + +func init() { + core.Register("ips", core.Manifest, &p5mParser{}, core.SuffixMatch(".p5m")) +} + +type p5mParser struct{} + +func (p *p5mParser) Parse(filename string, content []byte) ([]core.Dependency, error) { + deps := make([]core.Dependency, 0, core.EstimateDeps(len(content))) + text := string(content) + + // Join continuation lines (backslash-newline) into single lines. + var lines []string + var buf strings.Builder + core.ForEachLine(text, func(line string) bool { + trimmed := strings.TrimRight(line, " \t\r") + if before, ok := strings.CutSuffix(trimmed, "\\"); ok { + buf.WriteString(before) + buf.WriteByte(' ') + } else { + if buf.Len() > 0 { + buf.WriteString(trimmed) + lines = append(lines, buf.String()) + buf.Reset() + } else { + lines = append(lines, trimmed) + } + } + return true + }) + if buf.Len() > 0 { + lines = append(lines, buf.String()) + } + + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "depend ") { + continue + } + + // Skip macro references and TBD placeholders. + if strings.Contains(line, "$(") || strings.Contains(line, "fmri=__TBD") { + continue + } + + depType := extractAttr(line, "type=") + if depType == "" { + continue + } + + scope := mapScope(depType) + + // require-any can have multiple fmri= attributes. + fmris := extractAllAttrs(line, "fmri=") + for _, fmri := range fmris { + name, version := parseFMRI(fmri) + if name == "" { + continue + } + deps = append(deps, core.Dependency{ + Name: name, + Version: version, + Scope: scope, + Direct: true, + }) + } + } + + return deps, nil +} + +// parseFMRI extracts name and version from an IPS FMRI like +// "pkg:/library/libxml2@2.9.14,5.11-2024.0.0.0". +func parseFMRI(fmri string) (name, version string) { + // Strip pkg:/ or pkg:// prefix. + s := fmri + if strings.HasPrefix(s, "pkg://") { + s = s[len("pkg://"):] + // Skip publisher up to the first /. + if idx := strings.IndexByte(s, '/'); idx >= 0 { + s = s[idx+1:] + } + } else if strings.HasPrefix(s, "pkg:/") { + s = s[len("pkg:/"):] + } + + // Split on @ for name and version. + if before, after, ok := strings.Cut(s, "@"); ok { + name = before + version, _, _ = strings.Cut(after, ",") + } else { + name = s + } + return name, version +} + +func mapScope(depType string) core.Scope { + switch depType { + case "require", "require-any", "group", "incorporate": + return core.Runtime + case "optional", "conditional": + return core.Optional + default: + return core.Runtime + } +} + +// extractAttr returns the value of the first occurrence of key (e.g. "type=") +// in the space-delimited attribute line. +func extractAttr(line, key string) string { + _, after, ok := strings.Cut(line, key) + if !ok { + return "" + } + if val, _, ok := strings.Cut(after, " "); ok { + return val + } + return after +} + +// extractAllAttrs returns all values for a repeated attribute key. +func extractAllAttrs(line, key string) []string { + var vals []string + rest := line + for { + _, after, ok := strings.Cut(rest, key) + if !ok { + break + } + if val, remainder, ok := strings.Cut(after, " "); ok { + vals = append(vals, val) + rest = remainder + } else { + vals = append(vals, after) + break + } + } + return vals +} diff --git a/internal/ips/ips_test.go b/internal/ips/ips_test.go new file mode 100644 index 0000000..b42f5d4 --- /dev/null +++ b/internal/ips/ips_test.go @@ -0,0 +1,179 @@ +package ips + +import ( + "os" + "testing" + + "github.com/git-pkgs/manifests/internal/core" +) + +func TestP5mParse(t *testing.T) { + content, err := os.ReadFile("../../testdata/ips/sample.p5m") + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + parser := &p5mParser{} + deps, err := parser.Parse("sample.p5m", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(deps) != 8 { + for i, d := range deps { + t.Logf("dep[%d]: %s %s %s", i, d.Name, d.Version, d.Scope) + } + t.Fatalf("expected 8 dependencies, got %d", len(deps)) + } + + depMap := make(map[string]core.Dependency) + for _, d := range deps { + depMap[d.Name] = d + } + + // Runtime dependencies + for _, name := range []string{ + "library/libxml2", + "system/library", + "library/zlib", + "library/openssl", + } { + dep, ok := depMap[name] + if !ok { + t.Errorf("missing dependency %s", name) + continue + } + if dep.Scope != core.Runtime { + t.Errorf("%s scope = %q, want %q", name, dep.Scope, core.Runtime) + } + if !dep.Direct { + t.Errorf("%s should be direct", name) + } + } + + // Optional dependencies + for _, name := range []string{ + "developer/debug-tools", + "library/python/setuptools-311", + } { + dep, ok := depMap[name] + if !ok { + t.Errorf("missing dependency %s", name) + continue + } + if dep.Scope != core.Optional { + t.Errorf("%s scope = %q, want %q", name, dep.Scope, core.Optional) + } + } + + // require-any alternatives + for _, name := range []string{ + "web/server/apache-24", + "web/server/nginx", + } { + dep, ok := depMap[name] + if !ok { + t.Errorf("missing require-any dependency %s", name) + continue + } + if dep.Scope != core.Runtime { + t.Errorf("%s scope = %q, want %q", name, dep.Scope, core.Runtime) + } + } + + // Specific version checks + if dep := depMap["library/libxml2"]; dep.Version != "2.9.14" { + t.Errorf("libxml2 version = %q, want %q", dep.Version, "2.9.14") + } + if dep := depMap["system/library"]; dep.Version != "" { + t.Errorf("system/library version = %q, want empty", dep.Version) + } + if dep := depMap["library/zlib"]; dep.Version != "1.2.13" { + t.Errorf("zlib version = %q, want %q", dep.Version, "1.2.13") + } + if dep := depMap["library/openssl"]; dep.Version != "3.1.4" { + t.Errorf("openssl version = %q, want %q", dep.Version, "3.1.4") + } + if dep := depMap["web/server/nginx"]; dep.Version != "1.24.0" { + t.Errorf("nginx version = %q, want %q", dep.Version, "1.24.0") + } +} + +func TestP5mSkipsMacros(t *testing.T) { + content := []byte(`depend type=require fmri=pkg:/library/$(MACH64)/libfoo@1.0 +depend type=require fmri=__TBD pkg.debug.depend.file=libbar.so +depend type=require fmri=pkg:/library/libxml2@2.9 +`) + + parser := &p5mParser{} + deps, err := parser.Parse("test.p5m", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(deps) != 1 { + t.Fatalf("expected 1 dependency (skipping macro and TBD), got %d", len(deps)) + } + if deps[0].Name != "library/libxml2" { + t.Errorf("expected library/libxml2, got %s", deps[0].Name) + } +} + +func TestP5mMultilineContinuation(t *testing.T) { + content := []byte(`depend type=require-any \ + fmri=pkg:/editor/vim@9.0 \ + fmri=pkg:/editor/neovim@0.9 +`) + + parser := &p5mParser{} + deps, err := parser.Parse("test.p5m", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(deps) != 2 { + t.Fatalf("expected 2 dependencies from require-any, got %d", len(deps)) + } + + names := map[string]string{ + "editor/vim": "9.0", + "editor/neovim": "0.9", + } + for _, dep := range deps { + wantVer, ok := names[dep.Name] + if !ok { + t.Errorf("unexpected dependency %s", dep.Name) + continue + } + if dep.Version != wantVer { + t.Errorf("%s version = %q, want %q", dep.Name, dep.Version, wantVer) + } + if dep.Scope != core.Runtime { + t.Errorf("%s scope = %q, want %q", dep.Name, dep.Scope, core.Runtime) + } + } +} + +func TestParseFMRI(t *testing.T) { + tests := []struct { + input string + wantName string + wantVersion string + }{ + {"pkg:/library/zlib@1.2.13,5.11-2024.0.0.0", "library/zlib", "1.2.13"}, + {"pkg:/library/libxml2@2.9.14", "library/libxml2", "2.9.14"}, + {"pkg:/system/library", "system/library", ""}, + {"library/openssl@3.1", "library/openssl", "3.1"}, + {"pkg://openindiana.org/library/glib2@2.76,5.11-2024.0.0.0", "library/glib2", "2.76"}, + } + + for _, tt := range tests { + name, version := parseFMRI(tt.input) + if name != tt.wantName { + t.Errorf("parseFMRI(%q) name = %q, want %q", tt.input, name, tt.wantName) + } + if version != tt.wantVersion { + t.Errorf("parseFMRI(%q) version = %q, want %q", tt.input, version, tt.wantVersion) + } + } +} diff --git a/testdata/ips/sample.p5m b/testdata/ips/sample.p5m new file mode 100644 index 0000000..c8dd53d --- /dev/null +++ b/testdata/ips/sample.p5m @@ -0,0 +1,38 @@ +# This is a comment +set name=pkg.fmri value=pkg:/developer/example@1.0,5.11-2024.0.0.0 +set name=pkg.summary value="Example package" + +# Runtime dependency +depend type=require fmri=pkg:/library/libxml2@2.9.14 + +# Dependency with no version +depend type=require fmri=pkg:/system/library + +# Group dependency with build metadata in version +depend type=group fmri=pkg:/library/zlib@1.2.13,5.11-2024.0.0.0 + +# Incorporate dependency +depend type=incorporate fmri=pkg:/library/openssl@3.1.4,5.11-2024.0.0.1 + +# Optional dependency +depend type=optional fmri=pkg:/developer/debug-tools@1.0 + +# Conditional dependency +depend type=conditional predicate=pkg:/runtime/python-311 fmri=pkg:/library/python/setuptools-311@68.0.0 + +# Macro line - should be skipped +depend type=require fmri=pkg:/library/$(MACH)/libfoo@1.0 + +# TBD placeholder - should be skipped +depend type=require fmri=__TBD pkg.debug.depend.file=libbar.so + +# require-any with multiple fmri (multi-line with continuation) +depend type=require-any \ + fmri=pkg:/web/server/apache-24@2.4.58 \ + fmri=pkg:/web/server/nginx@1.24.0 + +# Non-depend line that mentions depend +set name=pkg.depend.runpath value=$PKGDEPEND_RUNPATH + +file path=usr/bin/example mode=0555 +dir path=usr/share/doc/example