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
77 changes: 69 additions & 8 deletions internal/golang/golang.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,56 +23,101 @@ var (

// Multi-line require block entry: example.com/pkg v1.2.3 // indirect
requireEntryRegex = regexp.MustCompile(`^\s*(\S+)\s+(\S+)(?:\s*//.*)?$`)

// Single-line tool: tool example.com/pkg/cmd/foo
singleToolRegex = regexp.MustCompile(`^\s*tool\s+(\S+)`)

// Multi-line tool block entry: example.com/pkg/cmd/foo
toolEntryRegex = regexp.MustCompile(`^\s*(\S+)\s*$`)
)

func (p *goModParser) Parse(filename string, content []byte) ([]core.Dependency, error) {
var deps []core.Dependency
lines := strings.Split(string(content), "\n")

// First pass: collect all tool paths
tools := make(map[string]bool)
inToolBlock := false

for _, line := range lines {
trimmed := strings.TrimSpace(line)

if trimmed == "" || strings.HasPrefix(trimmed, "//") {
continue
}

if strings.HasPrefix(trimmed, "tool (") || trimmed == "tool (" {
inToolBlock = true
continue
}

if inToolBlock && trimmed == ")" {
inToolBlock = false
continue
}

if strings.HasPrefix(trimmed, "tool ") && !strings.Contains(trimmed, "(") {
if match := singleToolRegex.FindStringSubmatch(trimmed); match != nil {
tools[match[1]] = true
}
continue
}

if inToolBlock {
if match := toolEntryRegex.FindStringSubmatch(trimmed); match != nil {
tools[match[1]] = true
}
}
}

// Second pass: parse require directives and check against tools
var deps []core.Dependency
inRequireBlock := false

for _, line := range lines {
trimmed := strings.TrimSpace(line)

// Skip empty lines and comments
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
continue
}

// Detect require block start
if strings.HasPrefix(trimmed, "require (") || trimmed == "require (" {
inRequireBlock = true
continue
}

// Detect require block end
if inRequireBlock && trimmed == ")" {
inRequireBlock = false
continue
}

// Single-line require
if strings.HasPrefix(trimmed, "require ") && !strings.Contains(trimmed, "(") {
if match := singleRequireRegex.FindStringSubmatch(trimmed); match != nil {
direct := !strings.Contains(line, "// indirect")
scope := core.Runtime
if isToolModule(match[1], tools) {
scope = core.Development
}
deps = append(deps, core.Dependency{
Name: match[1],
Version: match[2],
Scope: core.Runtime,
Scope: scope,
Direct: direct,
})
}
continue
}

// Inside require block
if inRequireBlock {
if match := requireEntryRegex.FindStringSubmatch(trimmed); match != nil {
direct := !strings.Contains(line, "// indirect")
scope := core.Runtime
if isToolModule(match[1], tools) {
scope = core.Development
}
deps = append(deps, core.Dependency{
Name: match[1],
Version: match[2],
Scope: core.Runtime,
Scope: scope,
Direct: direct,
})
}
Expand All @@ -82,6 +127,22 @@ func (p *goModParser) Parse(filename string, content []byte) ([]core.Dependency,
return deps, nil
}

// isToolModule checks if a module is used by any tool.
// A module matches if it equals a tool path or if a tool path starts with the module path.
func isToolModule(module string, tools map[string]bool) bool {
if tools[module] {
return true
}
// Check if any tool path starts with this module
// e.g., module "golang.org/x/tools" matches tool "golang.org/x/tools/cmd/stringer"
for tool := range tools {
if strings.HasPrefix(tool, module+"/") {
return true
}
}
return false
}

// goSumParser parses go.sum files.
type goSumParser struct{}

Expand Down
78 changes: 78 additions & 0 deletions internal/golang/golang_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ func TestGoMod(t *testing.T) {
t.Errorf("net version = %q, want %q", net.Version, "v1.2.3")
}
}

// Check tool dependencies are marked as Development scope
if report, ok := depMap["github.com/jstemmer/go-junit-report"]; !ok {
t.Error("expected github.com/jstemmer/go-junit-report dependency")
} else {
if report.Scope != core.Development {
t.Errorf("go-junit-report scope = %v, want Development", report.Scope)
}
}

if reportV2, ok := depMap["github.com/jstemmer/go-junit-report/v2"]; !ok {
t.Error("expected github.com/jstemmer/go-junit-report/v2 dependency")
} else {
if reportV2.Scope != core.Development {
t.Errorf("go-junit-report/v2 scope = %v, want Development", reportV2.Scope)
}
}

// Non-tool dependencies should be Runtime scope
if depMap["github.com/gomodule/redigo"].Scope != core.Runtime {
t.Errorf("redigo scope = %v, want Runtime", depMap["github.com/gomodule/redigo"].Scope)
}
}

func TestGoSum(t *testing.T) {
Expand Down Expand Up @@ -437,6 +459,62 @@ func TestGoSingleRequireMod(t *testing.T) {
}
}

func TestGoModToolDependencies(t *testing.T) {
// Test that tool dependencies are marked as Development scope
content := []byte(`module test

go 1.24

require (
example.com/runtime-pkg v1.0.0
example.com/tool-pkg v2.0.0
golang.org/x/tools v0.20.0
)

tool example.com/tool-pkg/cmd/mytool

tool (
golang.org/x/tools/cmd/stringer
)
`)

parser := &goModParser{}
deps, err := parser.Parse("go.mod", content)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}

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

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

// Runtime dependency should be Runtime scope
if runtime, ok := depMap["example.com/runtime-pkg"]; !ok {
t.Error("expected example.com/runtime-pkg dependency")
} else if runtime.Scope != core.Runtime {
t.Errorf("runtime-pkg scope = %v, want Runtime", runtime.Scope)
}

// Tool dependency (exact match) should be Development scope
if tool, ok := depMap["example.com/tool-pkg"]; !ok {
t.Error("expected example.com/tool-pkg dependency")
} else if tool.Scope != core.Development {
t.Errorf("tool-pkg scope = %v, want Development", tool.Scope)
}

// Tool dependency (module is prefix of tool path) should be Development scope
if tools, ok := depMap["golang.org/x/tools"]; !ok {
t.Error("expected golang.org/x/tools dependency")
} else if tools.Scope != core.Development {
t.Errorf("golang.org/x/tools scope = %v, want Development", tools.Scope)
}
}

func TestGoResolvedDepsJSON(t *testing.T) {
content, err := os.ReadFile("../../testdata/golang/go-resolved-dependencies.json")
if err != nil {
Expand Down
148 changes: 148 additions & 0 deletions internal/maven/gradle.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package maven

import (
"encoding/json"
"encoding/xml"
"errors"
"regexp"
"strings"

Expand All @@ -18,6 +20,12 @@ func init() {

// verification-metadata.xml - lockfile (gradle dependency verification)
core.Register("maven", core.Lockfile, &gradleVerificationParser{}, core.ExactMatch("verification-metadata.xml"))

// dependencies.lock - lockfile (Nebula dependency-lock plugin)
core.Register("maven", core.Lockfile, &nebulaLockParser{}, core.ExactMatch("dependencies.lock"))

// gradle-html-dependency-report.js - lockfile (gradle htmlDependencyReport task)
core.Register("maven", core.Lockfile, &gradleHtmlReportParser{}, core.ExactMatch("gradle-html-dependency-report.js"))
}

// gradleParser parses build.gradle and build.gradle.kts files.
Expand Down Expand Up @@ -324,3 +332,143 @@ func (p *gradleVerificationParser) Parse(filename string, content []byte) ([]cor

return deps, nil
}

// nebulaLockParser parses dependencies.lock files (Nebula gradle-dependency-lock-plugin).
type nebulaLockParser struct{}

func (p *nebulaLockParser) Parse(filename string, content []byte) ([]core.Dependency, error) {
var lockfile map[string]map[string]nebulaLockEntry
if err := json.Unmarshal(content, &lockfile); err != nil {
return nil, &core.ParseError{Filename: filename, Err: err}
}

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

for config, entries := range lockfile {
isTest := strings.Contains(strings.ToLower(config), "test")

for name, entry := range entries {
if entry.Locked == "" || seen[name] {
continue
}
seen[name] = true

scope := core.Runtime
if isTest {
scope = core.Test
}

// Direct deps have "requested", transitive have "firstLevelTransitive"
direct := entry.Requested != ""

deps = append(deps, core.Dependency{
Name: name,
Version: entry.Locked,
Scope: scope,
Direct: direct,
})
}
}

return deps, nil
}

type nebulaLockEntry struct {
Locked string `json:"locked"`
Requested string `json:"requested"`
FirstLevelTransitive []string `json:"firstLevelTransitive"`
Project bool `json:"project"`
}

// gradleHtmlReportParser parses gradle-html-dependency-report.js files.
type gradleHtmlReportParser struct{}

func (p *gradleHtmlReportParser) Parse(filename string, content []byte) ([]core.Dependency, error) {
// Extract JSON from: window.project = { ... };
text := string(content)

// Find the start of the JSON object
start := strings.Index(text, "window.project = ")
if start < 0 {
start = strings.Index(text, "window.project=")
if start < 0 {
return nil, &core.ParseError{Filename: filename, Err: errors.New("missing window.project assignment")}
}
start += len("window.project=")
} else {
start += len("window.project = ")
}

// Find the end (last } or };)
end := strings.LastIndex(text, "}")
if end < 0 || end < start {
return nil, &core.ParseError{Filename: filename, Err: errors.New("invalid JSON structure")}
}

jsonContent := text[start : end+1]

var project gradleHtmlProject
if err := json.Unmarshal([]byte(jsonContent), &project); err != nil {
return nil, &core.ParseError{Filename: filename, Err: err}
}

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

for _, config := range project.Configurations {
isTest := strings.Contains(strings.ToLower(config.Name), "test")
collectGradleHtmlDeps(&deps, seen, config.Dependencies, isTest)
}

return deps, nil
}

type gradleHtmlProject struct {
Name string `json:"name"`
Configurations []gradleHtmlConfig `json:"configurations"`
}

type gradleHtmlConfig struct {
Name string `json:"name"`
Dependencies []gradleHtmlDep `json:"dependencies"`
}

type gradleHtmlDep struct {
Module string `json:"module"`
Children []gradleHtmlDep `json:"children"`
}

func collectGradleHtmlDeps(deps *[]core.Dependency, seen map[string]bool, htmlDeps []gradleHtmlDep, isTest bool) {
for _, dep := range htmlDeps {
// Parse module: "group:artifact:version"
parts := strings.Split(dep.Module, ":")
if len(parts) < 3 {
continue
}

name := parts[0] + ":" + parts[1]
version := parts[2]

if !seen[name] {
seen[name] = true

scope := core.Runtime
if isTest {
scope = core.Test
}

*deps = append(*deps, core.Dependency{
Name: name,
Version: version,
Scope: scope,
Direct: false,
})
}

// Recursively collect children
if len(dep.Children) > 0 {
collectGradleHtmlDeps(deps, seen, dep.Children, isTest)
}
}
}
Loading