Skip to content

Commit e2dc096

Browse files
authored
Merge pull request stacktower-io#5 from ric2b/main
feat: add ruby support
2 parents ca173dd + fd41339 commit e2dc096

7 files changed

Lines changed: 482 additions & 6 deletions

File tree

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ stacktower parse javascript yup -o yup.json
4040

4141
# PHP (Packagist/Composer)
4242
stacktower parse php monolog/monolog -o monolog.json
43+
44+
# Ruby (RubyGems)
45+
stacktower parse ruby rspec -o rspec.json
4346
```
4447

4548
Add `--enrich` with a `GITHUB_TOKEN` to pull repository metadata (stars, maintainers, last commit) for richer visualizations.
@@ -162,7 +165,7 @@ The `--detailed` flag (node-link only) displays **all** meta keys in the node la
162165

163166
## How It Works
164167

165-
1. **Parse** — Fetch package metadata from registries (PyPI, crates.io, npm)
168+
1. **Parse** — Fetch package metadata from registries (PyPI, crates.io, npm, Packagist, RubyGems)
166169
2. **Reduce** — Remove transitive edges to show only direct dependencies
167170
3. **Layer** — Assign each package to a row based on its depth
168171
4. **Order** — Minimize edge crossings using branch-and-bound with PQ-tree pruning
@@ -184,16 +187,16 @@ HTTP responses are cached in `~/.cache/stacktower/` with a 24-hour TTL. Use `--r
184187

185188
## Adding New Languages
186189

187-
To add support for a new package manager (e.g., Ruby/RubyGems):
190+
To add support for a new package manager (e.g., Go/pkg.go.dev):
188191

189-
1. **Create a registry client** in `pkg/integrations/rubygems/client.go` — parse the registry API, extract dependencies, use `integrations.BaseClient` for HTTP + caching
192+
1. **Create a registry client** in `pkg/integrations/<registry>/client.go` — parse the registry API, extract dependencies, use `integrations.BaseClient` for HTTP + caching
190193

191-
2. **Create a source parser** in `pkg/source/ruby/ruby.go` — implement the `source.PackageInfo` interface (`GetName`, `GetVersion`, `GetDependencies`, `ToMetadata`, `ToRepoInfo`)
194+
2. **Create a source parser** in `pkg/source/<lang>/<lang>.go` — implement the `source.PackageInfo` interface (`GetName`, `GetVersion`, `GetDependencies`, `ToMetadata`, `ToRepoInfo`)
192195

193196
3. **Wire into CLI** in `internal/cli/parse.go`:
194197
```go
195-
cmd.AddCommand(newParserCmd("ruby <gem>", "Parse Ruby dependencies",
196-
func() (source.Parser, error) { return ruby.NewParser(source.DefaultCacheTTL) }, &opts))
198+
cmd.AddCommand(newParserCmd("<lang> <package>", "Parse <Lang> dependencies",
199+
func() (source.Parser, error) { return <lang>.NewParser(source.DefaultCacheTTL) }, &opts))
197200
```
198201

199202
The generic `source.Parse()` handles concurrent fetching, depth limits, and graph construction automatically.

examples/real/rspec.json

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
{
2+
"nodes": [
3+
{
4+
"id": "rspec",
5+
"meta": {
6+
"author": "Steven Baker, David Chelimsky, Myron Marston",
7+
"description": "BDD for Ruby",
8+
"downloads": 933712525,
9+
"license": "MIT",
10+
"version": "3.13.2"
11+
}
12+
},
13+
{
14+
"id": "rspec-core",
15+
"meta": {
16+
"author": "Steven Baker, David Chelimsky, Chad Humphries, Myron Marston",
17+
"description": "BDD for Ruby. RSpec runner and example groups.",
18+
"downloads": 1086232437,
19+
"license": "MIT",
20+
"version": "3.13.6"
21+
}
22+
},
23+
{
24+
"id": "rspec-expectations",
25+
"meta": {
26+
"author": "Steven Baker, David Chelimsky, Myron Marston",
27+
"description": "rspec-expectations provides a simple, readable API to express expected outcomes of a code example.",
28+
"downloads": 1087466323,
29+
"license": "MIT",
30+
"version": "3.13.5"
31+
}
32+
},
33+
{
34+
"id": "rspec-mocks",
35+
"meta": {
36+
"author": "Steven Baker, David Chelimsky, Myron Marston",
37+
"description": "RSpec's 'test double' framework, with support for stubbing and mocking",
38+
"downloads": 1080564537,
39+
"license": "MIT",
40+
"version": "3.13.7"
41+
}
42+
},
43+
{
44+
"id": "diff-lcs",
45+
"meta": {
46+
"author": "Austin Ziegler",
47+
"description": "Diff::LCS computes the difference between two Enumerable sequences using the\nMcIlroy-Hunt longest common subsequence (LCS) algorithm. It includes utilities\nto create a simple HTML diff output format and a standard diff-like tool.\n\nThis is release 1.6.1, providing a simple extension that allows for\nDiff::LCS::Change objects to be treated implicitly as arrays and fixes a number\nof formatting issues.\n\nRuby versions below 2.5 are soft-deprecated, which means that older versions are\nno longer part of the CI test suite. If any changes have been introduced that\nbreak those versions, bug reports and patches will be accepted, but it will be\nup to the reporter to verify any fixes prior to release. The next major release\nwill completely break compatibility.",
48+
"downloads": 1109763549,
49+
"license": "MIT, Artistic-1.0-Perl, GPL-2.0-or-later",
50+
"version": "1.6.2"
51+
}
52+
},
53+
{
54+
"id": "rspec-support",
55+
"meta": {
56+
"author": "David Chelimsky, Myron Marson, Jon Rowe, Sam Phippen, Xaviery Shay, Bradley Schaefer",
57+
"description": "Support utilities for RSpec gems",
58+
"downloads": 1070970150,
59+
"license": "MIT",
60+
"version": "3.13.6"
61+
}
62+
}
63+
],
64+
"edges": [
65+
{
66+
"from": "rspec",
67+
"to": "rspec-core"
68+
},
69+
{
70+
"from": "rspec",
71+
"to": "rspec-expectations"
72+
},
73+
{
74+
"from": "rspec",
75+
"to": "rspec-mocks"
76+
},
77+
{
78+
"from": "rspec-expectations",
79+
"to": "diff-lcs"
80+
},
81+
{
82+
"from": "rspec-expectations",
83+
"to": "rspec-support"
84+
},
85+
{
86+
"from": "rspec-core",
87+
"to": "rspec-support"
88+
},
89+
{
90+
"from": "rspec-mocks",
91+
"to": "diff-lcs"
92+
},
93+
{
94+
"from": "rspec-mocks",
95+
"to": "rspec-support"
96+
}
97+
]
98+
}

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/php"
1616
"github.com/matzehuels/stacktower/pkg/source/python"
17+
"github.com/matzehuels/stacktower/pkg/source/ruby"
1718
"github.com/matzehuels/stacktower/pkg/source/rust"
1819
)
1920

@@ -48,6 +49,8 @@ func newParseCmd() *cobra.Command {
4849
func() (source.Parser, error) { return rust.NewParser(source.DefaultCacheTTL) }, &opts))
4950
cmd.AddCommand(newParserCmd("javascript <package>", "Parse JavaScript package dependencies from npm",
5051
func() (source.Parser, error) { return javascript.NewParser(source.DefaultCacheTTL) }, &opts))
52+
cmd.AddCommand(newParserCmd("ruby <gem>", "Parse Ruby gem dependencies from RubyGems",
53+
func() (source.Parser, error) { return ruby.NewParser(source.DefaultCacheTTL) }, &opts))
5154
cmd.AddCommand(newParserCmd("php <package>", "Parse PHP (Composer) package dependencies from Packagist",
5255
func() (source.Parser, error) { return php.NewParser(source.DefaultCacheTTL) }, &opts))
5356

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package rubygems
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"time"
9+
10+
"github.com/matzehuels/stacktower/pkg/integrations"
11+
)
12+
13+
type GemInfo struct {
14+
Name string
15+
Version string
16+
Dependencies []string
17+
SourceCodeURI string
18+
HomepageURI string
19+
Description string
20+
License string
21+
Downloads int
22+
Authors string
23+
}
24+
25+
type Client struct {
26+
integrations.BaseClient
27+
baseURL string
28+
}
29+
30+
func NewClient(cacheTTL time.Duration) (*Client, error) {
31+
cache, err := integrations.NewCache(cacheTTL)
32+
if err != nil {
33+
return nil, err
34+
}
35+
return &Client{
36+
BaseClient: integrations.BaseClient{
37+
HTTP: integrations.NewHTTPClient(),
38+
Cache: cache,
39+
},
40+
baseURL: "https://rubygems.org/api/v1",
41+
}, nil
42+
}
43+
44+
func (c *Client) FetchGem(ctx context.Context, gem string, refresh bool) (*GemInfo, error) {
45+
gem = normalizeName(gem)
46+
cacheKey := "rubygems:" + gem
47+
48+
var info GemInfo
49+
err := c.FetchWithCache(ctx, cacheKey, refresh, func() error {
50+
return c.fetchGem(ctx, gem, &info)
51+
}, &info)
52+
if err != nil {
53+
return nil, err
54+
}
55+
return &info, nil
56+
}
57+
58+
func (c *Client) fetchGem(ctx context.Context, gem string, info *GemInfo) error {
59+
url := fmt.Sprintf("%s/gems/%s.json", c.baseURL, gem)
60+
61+
var data gemResponse
62+
if err := c.DoRequest(ctx, url, nil, &data); err != nil {
63+
if errors.Is(err, integrations.ErrNotFound) {
64+
return fmt.Errorf("%w: rubygems gem %s", err, gem)
65+
}
66+
return err
67+
}
68+
69+
*info = GemInfo{
70+
Name: data.Name,
71+
Version: data.Version,
72+
Description: data.Info,
73+
License: joinLicenses(data.Licenses),
74+
SourceCodeURI: data.SourceCodeURI,
75+
HomepageURI: data.HomepageURI,
76+
Downloads: data.Downloads,
77+
Authors: data.Authors,
78+
Dependencies: extractDeps(data.Dependencies),
79+
}
80+
return nil
81+
}
82+
83+
func extractDeps(deps dependenciesResponse) []string {
84+
seen := make(map[string]bool)
85+
var result []string
86+
87+
// Only include runtime dependencies, skip development dependencies
88+
for _, dep := range deps.Runtime {
89+
name := normalizeName(dep.Name)
90+
if !seen[name] {
91+
seen[name] = true
92+
result = append(result, name)
93+
}
94+
}
95+
return result
96+
}
97+
98+
func joinLicenses(licenses []string) string {
99+
if len(licenses) == 0 {
100+
return ""
101+
}
102+
return strings.Join(licenses, ", ")
103+
}
104+
105+
func normalizeName(name string) string {
106+
return strings.ToLower(strings.TrimSpace(name))
107+
}
108+
109+
type gemResponse struct {
110+
Name string `json:"name"`
111+
Version string `json:"version"`
112+
Info string `json:"info"`
113+
Licenses []string `json:"licenses"`
114+
SourceCodeURI string `json:"source_code_uri"`
115+
HomepageURI string `json:"homepage_uri"`
116+
Downloads int `json:"downloads"`
117+
Authors string `json:"authors"`
118+
Dependencies dependenciesResponse `json:"dependencies"`
119+
}
120+
121+
type dependenciesResponse struct {
122+
Development []dependencyInfo `json:"development"`
123+
Runtime []dependencyInfo `json:"runtime"`
124+
}
125+
126+
type dependencyInfo struct {
127+
Name string `json:"name"`
128+
Requirements string `json:"requirements"`
129+
}

0 commit comments

Comments
 (0)