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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
146 changes: 146 additions & 0 deletions internal/ips/ips.go
Original file line number Diff line number Diff line change
@@ -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
}
179 changes: 179 additions & 0 deletions internal/ips/ips_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
38 changes: 38 additions & 0 deletions testdata/ips/sample.p5m
Original file line number Diff line number Diff line change
@@ -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