Skip to content

Commit 8764839

Browse files
committed
feat: add PHP support
1 parent 425d206 commit 8764839

4 files changed

Lines changed: 312 additions & 0 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ stacktower parse rust serde -o serde.json
3737

3838
# JavaScript (npm)
3939
stacktower parse javascript yup -o yup.json
40+
41+
# PHP (Packagist/Composer)
42+
stacktower parse php monolog/monolog -o monolog.json
4043
```
4144

4245
Add `--enrich` with a `GITHUB_TOKEN` to pull repository metadata (stars, maintainers, last commit) for richer visualizations.

internal/cli/parse.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/matzehuels/stacktower/pkg/source/metadata"
1515
"github.com/matzehuels/stacktower/pkg/source/python"
1616
"github.com/matzehuels/stacktower/pkg/source/rust"
17+
"github.com/matzehuels/stacktower/pkg/source/php"
1718
)
1819

1920
type parseOpts struct {
@@ -47,6 +48,8 @@ func newParseCmd() *cobra.Command {
4748
func() (source.Parser, error) { return rust.NewParser(source.DefaultCacheTTL) }, &opts))
4849
cmd.AddCommand(newParserCmd("javascript <package>", "Parse JavaScript package dependencies from npm",
4950
func() (source.Parser, error) { return javascript.NewParser(source.DefaultCacheTTL) }, &opts))
51+
cmd.AddCommand(newParserCmd("php <package>", "Parse PHP (Composer) package dependencies from Packagist",
52+
func() (source.Parser, error) { return php.NewParser(source.DefaultCacheTTL) }, &opts))
5053

5154
return cmd
5255
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package packagist
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"maps"
9+
"slices"
10+
"strings"
11+
"time"
12+
13+
"github.com/matzehuels/stacktower/pkg/integrations"
14+
)
15+
16+
type PackageInfo struct {
17+
Name string
18+
Version string
19+
Dependencies []string
20+
Repository string
21+
HomePage string
22+
Description string
23+
License string
24+
Author string
25+
}
26+
27+
type Client struct {
28+
integrations.BaseClient
29+
baseURL string
30+
}
31+
32+
func NewClient(cacheTTL time.Duration) (*Client, error) {
33+
cache, err := integrations.NewCache(cacheTTL)
34+
if err != nil {
35+
return nil, err
36+
}
37+
38+
return &Client{
39+
BaseClient: integrations.BaseClient{
40+
HTTP: integrations.NewHTTPClient(),
41+
Cache: cache,
42+
},
43+
baseURL: "https://repo.packagist.org",
44+
}, nil
45+
}
46+
47+
func (c *Client) FetchPackage(ctx context.Context, pkg string, refresh bool) (*PackageInfo, error) {
48+
pkg = normalizeName(pkg)
49+
cacheKey := "packagist:" + pkg
50+
51+
var info PackageInfo
52+
err := c.FetchWithCache(ctx, cacheKey, refresh, func() error {
53+
return c.fetchPackage(ctx, pkg, &info)
54+
}, &info)
55+
if err != nil {
56+
return nil, err
57+
}
58+
59+
return &info, nil
60+
}
61+
62+
func (c *Client) fetchPackage(ctx context.Context, pkg string, info *PackageInfo) error {
63+
url := fmt.Sprintf("%s/p2/%s.json", c.baseURL, pkg)
64+
65+
var data p2Response
66+
if err := c.DoRequest(ctx, url, nil, &data); err != nil {
67+
if errors.Is(err, integrations.ErrNotFound) {
68+
return fmt.Errorf("%w: packagist package %s", err, pkg)
69+
}
70+
return err
71+
}
72+
73+
versions, ok := data.Packages[pkg]
74+
if !ok || len(versions) == 0 {
75+
return fmt.Errorf("no versions found for %s", pkg)
76+
}
77+
78+
v := chooseLatestStable(versions)
79+
deps := filterComposerDeps(v.Require)
80+
81+
license := ""
82+
if len(v.License) > 0 {
83+
license = v.License[0]
84+
}
85+
86+
author := ""
87+
if len(v.Authors) > 0 {
88+
author = strings.TrimSpace(v.Authors[0].Name)
89+
}
90+
91+
*info = PackageInfo{
92+
Name: v.Name,
93+
Version: v.Version,
94+
Description: v.Description,
95+
License: license,
96+
Author: author,
97+
Repository: normalizeRepoURL(v.Source.URL),
98+
HomePage: v.Homepage,
99+
Dependencies: slices.Collect(maps.Keys(deps)),
100+
}
101+
102+
return nil
103+
}
104+
105+
func filterComposerDeps(require map[string]string) map[string]string {
106+
if require == nil {
107+
return map[string]string{}
108+
}
109+
110+
deps := make(map[string]string)
111+
for name, constraint := range require {
112+
ln := strings.ToLower(name)
113+
114+
if ln == "php" || strings.HasPrefix(ln, "ext-") || strings.HasPrefix(ln, "lib-") ||
115+
ln == "composer-plugin-api" || ln == "composer-runtime-api" {
116+
continue
117+
}
118+
119+
if strings.Contains(ln, "/") {
120+
deps[ln] = constraint
121+
}
122+
}
123+
124+
return deps
125+
}
126+
127+
func chooseLatestStable(versions []p2Version) p2Version {
128+
for _, v := range versions {
129+
lv := strings.ToLower(v.Version)
130+
131+
if strings.Contains(lv, "dev") {
132+
continue
133+
}
134+
135+
versionNum := strings.TrimPrefix(lv, "v")
136+
if strings.Contains(versionNum, ".") {
137+
return v
138+
}
139+
}
140+
return versions[0]
141+
}
142+
143+
func normalizeName(name string) string {
144+
return strings.TrimSpace(strings.ToLower(name))
145+
}
146+
147+
func normalizeRepoURL(url string) string {
148+
if url == "" {
149+
return ""
150+
}
151+
152+
url = strings.TrimSpace(url)
153+
url = strings.TrimPrefix(url, "git+")
154+
url = strings.ReplaceAll(url, "git@github.com:", "https://github.com/")
155+
url = strings.ReplaceAll(url, "git://github.com/", "https://github.com/")
156+
url = strings.TrimSuffix(url, ".git")
157+
158+
return url
159+
}
160+
161+
type p2Response struct {
162+
Packages map[string][]p2Version `json:"packages"`
163+
}
164+
165+
type p2Version struct {
166+
Name string `json:"name"`
167+
Version string `json:"version"`
168+
Description string `json:"description"`
169+
Homepage string `json:"homepage"`
170+
License []string `json:"license"`
171+
Require map[string]string `json:"require"`
172+
Support map[string]string `json:"support"`
173+
Source struct{ URL string `json:"url"` } `json:"source"`
174+
Dist struct{ URL string `json:"url"` } `json:"dist"`
175+
Authors []struct{ Name string `json:"name"` } `json:"authors"`
176+
}
177+
178+
func (v *p2Version) UnmarshalJSON(b []byte) error {
179+
type rawVersion struct {
180+
Name string `json:"name"`
181+
Version string `json:"version"`
182+
Description string `json:"description"`
183+
Homepage string `json:"homepage"`
184+
License json.RawMessage `json:"license"`
185+
Require json.RawMessage `json:"require"`
186+
Support map[string]string `json:"support"`
187+
Source struct{ URL string `json:"url"` } `json:"source"`
188+
Dist struct{ URL string `json:"url"` } `json:"dist"`
189+
Authors []struct{ Name string `json:"name"` } `json:"authors"`
190+
}
191+
192+
var rv rawVersion
193+
if err := json.Unmarshal(b, &rv); err != nil {
194+
return err
195+
}
196+
197+
var license []string
198+
if len(rv.License) > 0 && string(rv.License) != "null" {
199+
if err := json.Unmarshal(rv.License, &license); err != nil {
200+
var single string
201+
if err := json.Unmarshal(rv.License, &single); err == nil && single != "" {
202+
license = []string{single}
203+
}
204+
}
205+
}
206+
207+
require := map[string]string{}
208+
if len(rv.Require) > 0 && string(rv.Require) != "null" {
209+
if err := json.Unmarshal(rv.Require, &require); err != nil {
210+
var anyObj map[string]any
211+
if err := json.Unmarshal(rv.Require, &anyObj); err == nil {
212+
require = make(map[string]string, len(anyObj))
213+
for k, val := range anyObj {
214+
if s, ok := val.(string); ok {
215+
require[k] = s
216+
}
217+
}
218+
}
219+
}
220+
}
221+
222+
v.Name = rv.Name
223+
v.Version = rv.Version
224+
v.Description = rv.Description
225+
v.Homepage = rv.Homepage
226+
v.License = license
227+
v.Require = require
228+
v.Support = rv.Support
229+
v.Source = rv.Source
230+
v.Dist = rv.Dist
231+
v.Authors = rv.Authors
232+
233+
return nil
234+
}

pkg/source/php/php.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package php
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/matzehuels/stacktower/pkg/dag"
8+
"github.com/matzehuels/stacktower/pkg/integrations/packagist"
9+
"github.com/matzehuels/stacktower/pkg/source"
10+
)
11+
12+
// Parser implements source.Parser for PHP/Composer via Packagist
13+
type Parser struct {
14+
client *packagist.Client
15+
}
16+
17+
func NewParser(cacheTTL time.Duration) (*Parser, error) {
18+
c, err := packagist.NewClient(cacheTTL)
19+
if err != nil {
20+
return nil, err
21+
}
22+
return &Parser{client: c}, nil
23+
}
24+
25+
func (p *Parser) Parse(ctx context.Context, pkg string, opts source.Options) (*dag.DAG, error) {
26+
return source.Parse(ctx, pkg, opts, p.fetch)
27+
}
28+
29+
func (p *Parser) fetch(ctx context.Context, name string, refresh bool) (*packageInfo, error) {
30+
info, err := p.client.FetchPackage(ctx, name, refresh)
31+
if err != nil {
32+
return nil, err
33+
}
34+
return &packageInfo{info}, nil
35+
}
36+
37+
type packageInfo struct { *packagist.PackageInfo }
38+
39+
func (pi *packageInfo) GetName() string { return pi.Name }
40+
func (pi *packageInfo) GetVersion() string { return pi.Version }
41+
func (pi *packageInfo) GetDependencies() []string { return pi.Dependencies }
42+
43+
func (pi *packageInfo) ToMetadata() map[string]any {
44+
m := map[string]any{"version": pi.Version}
45+
if pi.Description != "" {
46+
m["description"] = pi.Description
47+
}
48+
if pi.License != "" {
49+
m["license"] = pi.License
50+
}
51+
if pi.Author != "" {
52+
m["author"] = pi.Author
53+
}
54+
return m
55+
}
56+
57+
func (pi *packageInfo) ToRepoInfo() *source.RepoInfo {
58+
urls := make(map[string]string, 2)
59+
if pi.Repository != "" {
60+
urls["repository"] = pi.Repository
61+
}
62+
if pi.HomePage != "" {
63+
urls["homepage"] = pi.HomePage
64+
}
65+
return &source.RepoInfo{
66+
Name: pi.Name,
67+
Version: pi.Version,
68+
ProjectURLs: urls,
69+
HomePage: pi.HomePage,
70+
ManifestFile: "composer.json",
71+
}
72+
}

0 commit comments

Comments
 (0)