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
82 changes: 82 additions & 0 deletions internal/maven/ivy.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import (

func init() {
core.Register("maven", core.Manifest, &ivyXMLParser{}, core.ExactMatch("ivy.xml"))

// ivy-report.xml - lockfile (sbt dependencyLookup output)
// Files are named {org}-{module}-{conf}.xml (e.g., com.example-hello_2.12-compile.xml)
core.Register("maven", core.Lockfile, &ivyReportParser{}, ivyReportMatcher)
}

// ivyXMLParser parses ivy.xml files.
Expand Down Expand Up @@ -63,3 +67,81 @@ func (p *ivyXMLParser) Parse(filename string, content []byte) ([]core.Dependency

return deps, nil
}

// ivyReportMatcher matches ivy report files (e.g., com.example-hello-compile.xml)
func ivyReportMatcher(filename string) bool {
// Match files ending in -compile.xml, -test.xml, -runtime.xml, etc.
// These are generated by sbt's dependencyLookup command
suffixes := []string{"-compile.xml", "-test.xml", "-runtime.xml", "-provided.xml"}
for _, suffix := range suffixes {
if strings.HasSuffix(filename, suffix) {
return true
}
}
return false
}

// ivyReportParser parses ivy report XML files.
type ivyReportParser struct{}

type ivyReport struct {
Info ivyReportInfo `xml:"info"`
Dependencies ivyReportDeps `xml:"dependencies"`
}

type ivyReportInfo struct {
Conf string `xml:"conf,attr"`
}

type ivyReportDeps struct {
Modules []ivyReportModule `xml:"module"`
}

type ivyReportModule struct {
Org string `xml:"organisation,attr"`
Name string `xml:"name,attr"`
Revisions []ivyReportRevision `xml:"revision"`
}

type ivyReportRevision struct {
Name string `xml:"name,attr"`
}

func (p *ivyReportParser) Parse(filename string, content []byte) ([]core.Dependency, error) {
var report ivyReport
if err := xml.Unmarshal(content, &report); err != nil {
return nil, &core.ParseError{Filename: filename, Err: err}
}

var deps []core.Dependency
seen := make(map[string]bool)

// Determine scope from the info conf attribute
scope := core.Runtime
conf := strings.ToLower(report.Info.Conf)
if strings.Contains(conf, "test") {
scope = core.Test
}

for _, mod := range report.Dependencies.Modules {
name := mod.Org + ":" + mod.Name
if seen[name] {
continue
}
seen[name] = true

version := ""
if len(mod.Revisions) > 0 {
version = mod.Revisions[0].Name
}

deps = append(deps, core.Dependency{
Name: name,
Version: version,
Scope: scope,
Direct: false, // Ivy reports show resolved deps, not just direct
})
}

return deps, nil
}
160 changes: 160 additions & 0 deletions internal/maven/maven_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -770,3 +770,163 @@ func TestGradleHtmlReport(t *testing.T) {
}
}
}

func TestIvyReportCompile(t *testing.T) {
content, err := os.ReadFile("../../testdata/maven/ivy_reports/com.example-hello_2.12-compile.xml")
if err != nil {
t.Fatalf("failed to read fixture: %v", err)
}

parser := &ivyReportParser{}
deps, err := parser.Parse("com.example-hello_2.12-compile.xml", content)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}

if len(deps) != 1 {
t.Fatalf("expected 1 dependency, got %d", len(deps))
}

dep := deps[0]
if dep.Name != "org.scala-lang:scala-library" {
t.Errorf("name = %q, want %q", dep.Name, "org.scala-lang:scala-library")
}
if dep.Version != "2.12.5" {
t.Errorf("version = %q, want %q", dep.Version, "2.12.5")
}
if dep.Scope != core.Runtime {
t.Errorf("scope = %v, want Runtime", dep.Scope)
}
}

func TestIvyReportTest(t *testing.T) {
content, err := os.ReadFile("../../testdata/maven/ivy_reports/com.example-hello_2.12-test.xml")
if err != nil {
t.Fatalf("failed to read fixture: %v", err)
}

parser := &ivyReportParser{}
deps, err := parser.Parse("com.example-hello_2.12-test.xml", content)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}

if len(deps) != 5 {
t.Fatalf("expected 5 dependencies, got %d", len(deps))
}

depMap := make(map[string]core.Dependency)
for _, d := range deps {
depMap[d.Name] = d
}

// Verify some dependencies and that they have test scope
expectedTest := map[string]string{
"org.scala-lang:scala-reflect": "2.12.5",
"org.scalatest:scalatest_2.12": "3.0.5",
"org.scala-lang.modules:scala-xml_2.12": "1.0.6",
"org.scalactic:scalactic_2.12": "3.0.5",
"org.scala-lang:scala-library": "2.12.5",
}

for name, wantVer := range expectedTest {
dep, ok := depMap[name]
if !ok {
t.Errorf("expected %s dependency", name)
continue
}
if dep.Version != wantVer {
t.Errorf("%s version = %q, want %q", name, dep.Version, wantVer)
}
if dep.Scope != core.Test {
t.Errorf("%s scope = %v, want Test", name, dep.Scope)
}
}
}

func TestIvyReportMatcher(t *testing.T) {
tests := []struct {
filename string
match bool
}{
{"com.example-hello_2.12-compile.xml", true},
{"com.example-hello_2.12-test.xml", true},
{"com.example-hello_2.12-runtime.xml", true},
{"com.example-hello_2.12-provided.xml", true},
{"ivy.xml", false},
{"pom.xml", false},
{"build.gradle", false},
}

for _, tc := range tests {
if got := ivyReportMatcher(tc.filename); got != tc.match {
t.Errorf("ivyReportMatcher(%q) = %v, want %v", tc.filename, got, tc.match)
}
}
}

func TestSbtDot(t *testing.T) {
content, err := os.ReadFile("../../testdata/maven/dependencies-compile.dot")
if err != nil {
t.Fatalf("failed to read fixture: %v", err)
}

parser := &sbtDotParser{}
deps, err := parser.Parse("dependencies-compile.dot", content)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}

if len(deps) != 6 {
t.Fatalf("expected 6 dependencies, got %d", len(deps))
}

depMap := make(map[string]core.Dependency)
for _, d := range deps {
depMap[d.Name] = d
}

// Verify dependencies
expected := map[string]string{
"com.example:myproject": "1.0.0",
"org.scala-lang:scala-library": "2.12.5",
"com.typesafe:config": "1.3.4",
"ch.qos.logback:logback-classic": "1.2.3",
"ch.qos.logback:logback-core": "1.2.3",
"org.slf4j:slf4j-api": "1.7.25",
}

for name, wantVer := range expected {
dep, ok := depMap[name]
if !ok {
t.Errorf("expected %s dependency", name)
continue
}
if dep.Version != wantVer {
t.Errorf("%s version = %q, want %q", name, dep.Version, wantVer)
}
if dep.Scope != core.Runtime {
t.Errorf("%s scope = %v, want Runtime", name, dep.Scope)
}
}
}

func TestSbtDotMatcher(t *testing.T) {
tests := []struct {
filename string
match bool
}{
{"dependencies-compile.dot", true},
{"dependencies-test.dot", true},
{"dependencies-runtime.dot", true},
{"build.sbt", false},
{"pom.xml", false},
{"graph.dot", false},
}

for _, tc := range tests {
if got := sbtDotMatcher(tc.filename); got != tc.match {
t.Errorf("sbtDotMatcher(%q) = %v, want %v", tc.filename, got, tc.match)
}
}
}
48 changes: 48 additions & 0 deletions internal/maven/sbt.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import (

func init() {
core.Register("maven", core.Manifest, &sbtParser{}, core.ExactMatch("build.sbt"))

// dependencies-*.dot - lockfile (sbt dependencyDot output)
core.Register("maven", core.Lockfile, &sbtDotParser{}, sbtDotMatcher)
}

// sbtParser parses build.sbt files.
Expand Down Expand Up @@ -79,3 +82,48 @@ func (p *sbtParser) Parse(filename string, content []byte) ([]core.Dependency, e

return deps, nil
}

// sbtDotMatcher matches sbt dependency DOT files (e.g., dependencies-compile.dot)
func sbtDotMatcher(filename string) bool {
return strings.HasPrefix(filename, "dependencies-") && strings.HasSuffix(filename, ".dot")
}

// sbtDotParser parses sbt dependencyDot output files.
type sbtDotParser struct{}

// Match "group:artifact:version" in DOT node/edge definitions
var sbtDotDepRegex = regexp.MustCompile(`"([^":]+):([^":]+):([^"]+)"`)

func (p *sbtDotParser) Parse(filename string, content []byte) ([]core.Dependency, error) {
var deps []core.Dependency
seen := make(map[string]bool)
text := string(content)

// Determine scope from filename
scope := core.Runtime
if strings.Contains(filename, "-test") {
scope = core.Test
}

// Find all group:artifact:version patterns
for _, match := range sbtDotDepRegex.FindAllStringSubmatch(text, -1) {
group := match[1]
artifact := match[2]
version := match[3]

name := group + ":" + artifact
if seen[name] {
continue
}
seen[name] = true

deps = append(deps, core.Dependency{
Name: name,
Version: version,
Scope: scope,
Direct: false, // DOT file shows full graph, not just direct deps
})
}

return deps, nil
}
17 changes: 17 additions & 0 deletions testdata/maven/dependencies-compile.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
digraph "com.example:myproject:1.0.0" {
graph[rankdir="LR"]
node [
shape="record"
]
"com.example:myproject:1.0.0"[label=<com.example<BR/><B>myproject</B><BR/>1.0.0>]
"com.example:myproject:1.0.0" -> "org.scala-lang:scala-library:2.12.5"
"org.scala-lang:scala-library:2.12.5"[label=<org.scala-lang<BR/><B>scala-library</B><BR/>2.12.5>]
"com.example:myproject:1.0.0" -> "com.typesafe:config:1.3.4"
"com.typesafe:config:1.3.4"[label=<com.typesafe<BR/><B>config</B><BR/>1.3.4>]
"com.example:myproject:1.0.0" -> "ch.qos.logback:logback-classic:1.2.3"
"ch.qos.logback:logback-classic:1.2.3"[label=<ch.qos.logback<BR/><B>logback-classic</B><BR/>1.2.3>]
"ch.qos.logback:logback-classic:1.2.3" -> "ch.qos.logback:logback-core:1.2.3"
"ch.qos.logback:logback-core:1.2.3"[label=<ch.qos.logback<BR/><B>logback-core</B><BR/>1.2.3>]
"ch.qos.logback:logback-classic:1.2.3" -> "org.slf4j:slf4j-api:1.7.25"
"org.slf4j:slf4j-api:1.7.25"[label=<org.slf4j<BR/><B>slf4j-api</B><BR/>1.7.25>]
}