Skip to content

Commit 82d57d4

Browse files
authored
Merge pull request stacktower-io#11 from murrant/php-support
feat: add PHP support
2 parents 425d206 + fd3fec4 commit 82d57d4

6 files changed

Lines changed: 620 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
@@ -12,6 +12,7 @@ import (
1212
"github.com/matzehuels/stacktower/pkg/source"
1313
"github.com/matzehuels/stacktower/pkg/source/javascript"
1414
"github.com/matzehuels/stacktower/pkg/source/metadata"
15+
"github.com/matzehuels/stacktower/pkg/source/php"
1516
"github.com/matzehuels/stacktower/pkg/source/python"
1617
"github.com/matzehuels/stacktower/pkg/source/rust"
1718
)
@@ -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: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
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 {
174+
URL string `json:"url"`
175+
} `json:"source"`
176+
Dist struct {
177+
URL string `json:"url"`
178+
} `json:"dist"`
179+
Authors []struct {
180+
Name string `json:"name"`
181+
} `json:"authors"`
182+
}
183+
184+
func (v *p2Version) UnmarshalJSON(b []byte) error {
185+
type rawVersion struct {
186+
Name string `json:"name"`
187+
Version string `json:"version"`
188+
Description string `json:"description"`
189+
Homepage string `json:"homepage"`
190+
License json.RawMessage `json:"license"`
191+
Require json.RawMessage `json:"require"`
192+
Support map[string]string `json:"support"`
193+
Source struct {
194+
URL string `json:"url"`
195+
} `json:"source"`
196+
Dist struct {
197+
URL string `json:"url"`
198+
} `json:"dist"`
199+
Authors []struct {
200+
Name string `json:"name"`
201+
} `json:"authors"`
202+
}
203+
204+
var rv rawVersion
205+
if err := json.Unmarshal(b, &rv); err != nil {
206+
return err
207+
}
208+
209+
var license []string
210+
if len(rv.License) > 0 && string(rv.License) != "null" {
211+
if err := json.Unmarshal(rv.License, &license); err != nil {
212+
var single string
213+
if err := json.Unmarshal(rv.License, &single); err == nil && single != "" {
214+
license = []string{single}
215+
}
216+
}
217+
}
218+
219+
require := map[string]string{}
220+
if len(rv.Require) > 0 && string(rv.Require) != "null" {
221+
if err := json.Unmarshal(rv.Require, &require); err != nil {
222+
var anyObj map[string]any
223+
if err := json.Unmarshal(rv.Require, &anyObj); err == nil {
224+
require = make(map[string]string, len(anyObj))
225+
for k, val := range anyObj {
226+
if s, ok := val.(string); ok {
227+
require[k] = s
228+
}
229+
}
230+
}
231+
}
232+
}
233+
234+
v.Name = rv.Name
235+
v.Version = rv.Version
236+
v.Description = rv.Description
237+
v.Homepage = rv.Homepage
238+
v.License = license
239+
v.Require = require
240+
v.Support = rv.Support
241+
v.Source = rv.Source
242+
v.Dist = rv.Dist
243+
v.Authors = rv.Authors
244+
245+
return nil
246+
}

0 commit comments

Comments
 (0)