Skip to content

Commit e346282

Browse files
feat: update Gradle parser and add implementation plan
1 parent 9e104f5 commit e346282

3 files changed

Lines changed: 365 additions & 56 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Gradle Parser Implementation Plan
2+
3+
## Overview
4+
This document describes the implementation plan and execution steps for adding Gradle manifest parsing support to the `manifest-parser` repository.
5+
6+
The parser was extended to support static Gradle dependency declarations in both Groovy and Kotlin DSL, including common production patterns such as multi-line dependencies and conditional `if` blocks.
7+
8+
---
9+
10+
## Implementation Plan
11+
12+
### 1. Analyze existing code flow
13+
- Inspect `cmd/main.go` to understand entrypoint behavior.
14+
- Review `pkg/parser/parser.go` and `pkg/parser/parser_factory.go` to understand the parser interface and factory logic.
15+
- Review `pkg/parser/manifest-file-selector.go` to see how manifest types are detected.
16+
- Review existing language parser implementations for style and output format.
17+
18+
### 2. Add Gradle manifest detection
19+
- Extend `pkg/parser/manifest-file-selector.go` to recognize `build.gradle` and `build.gradle.kts` files.
20+
- Add a new `Manifest` type for Gradle.
21+
22+
### 3. Add factory support for Gradle
23+
- Update `pkg/parser/parser_factory.go` to import the new Gradle parser.
24+
- Return the Gradle parser instance when the selected manifest is Gradle.
25+
26+
### 4. Implement Gradle parser
27+
- Create `internal/parsers/gradle/gradle_parser.go`.
28+
- Implement the `Parser` interface for Gradle.
29+
- Parse dependencies into `models.Package` entries.
30+
31+
### 5. Add variable resolution
32+
- Read `gradle.properties` values.
33+
- Parse Groovy `ext {}` blocks and `ext.key` assignments.
34+
- Parse Kotlin DSL `val` and `const val` declarations.
35+
- Resolve `${var}` and `$var` references in dependency strings.
36+
37+
### 6. Add support for multi-line dependency declarations
38+
- Detect dependency statements spanning multiple lines.
39+
- Join logical dependency lines before parsing.
40+
- Support both string notation and map notation across line breaks.
41+
42+
### 7. Add conditional dependency support
43+
- Parse dependencies inside conditional blocks such as `if (...) { ... }`.
44+
- Treat static declarations inside conditionals as valid parse targets.
45+
46+
### 8. Add Kotlin DSL support
47+
- Support Kotlin string syntax: `implementation("group:name:version")`.
48+
- Support Kotlin map syntax: `implementation(group = "group", name = "name", version = "version")`.
49+
- Support Kotlin-style dependency declarations in `build.gradle.kts`.
50+
51+
### 9. Write regression tests
52+
- Create or update `internal/parsers/gradle/gradle_parser_test.go`.
53+
- Add tests for:
54+
- basic Groovy dependencies
55+
- multi-line dependencies
56+
- conditional `if` block dependencies
57+
- Kotlin DSL dependency syntax
58+
59+
### 10. Validate
60+
- Run `go test ./internal/parsers/gradle`.
61+
- Confirm Gradle parser tests pass.
62+
- Optionally run `go test ./...` to verify broader repository compatibility, noting existing unrelated test failures.
63+
64+
---
65+
66+
## Files created or modified
67+
68+
- `pkg/parser/manifest-file-selector.go`
69+
- `pkg/parser/parser_factory.go`
70+
- `internal/parsers/gradle/gradle_parser.go`
71+
- `internal/parsers/gradle/gradle_parser_test.go`
72+
- `test/resources/build.gradle` (sample Gradle fixture)
73+
74+
---
75+
76+
## Supported Gradle parser features
77+
78+
- Detection of `build.gradle` and `build.gradle.kts` files
79+
- Parsing of common dependency configurations:
80+
- `implementation`, `api`, `compile`, `compileOnly`, `runtime`, `runtimeOnly`
81+
- `testImplementation`, `testCompile`, `testRuntimeOnly`
82+
- `androidTestImplementation`, `annotationProcessor`, `classpath`, `kapt`
83+
- String-style dependency declarations
84+
- Map-style dependency declarations
85+
- Multi-line dependency statements
86+
- Dependencies inside `if (...) { ... }` blocks
87+
- Variable resolution from:
88+
- `gradle.properties`
89+
- Groovy `ext` property blocks
90+
- Groovy `ext.key = value` syntax
91+
- Kotlin DSL `val` / `const val`
92+
- Kotlin DSL dependency syntax
93+
- Version cleanup for simple ranges and classifiers
94+
95+
---
96+
97+
## Known limitations
98+
99+
- Dynamic dependencies generated by build logic, loops, or plugin APIs are not resolved.
100+
- Complex Kotlin DSL constructs beyond common forms may not be fully parsed.
101+
- Conditional branch logic is not evaluated; all static declarations are treated as present.
102+
- Deep nested DSL or custom Gradle extension syntax may be missed.
103+
- Computed or function-based version expressions are not evaluated.
104+
- Multi-project and included-build dependency resolution is not supported.
105+
106+
---
107+
108+
## Notes
109+
110+
The Gradle parser is now suitable for many production scanning scenarios in AST-CLI where static dependency declarations are present. For full Gradle model accuracy, additional Gradle-aware parsing or integration with Gradle tooling would be required.

internal/parsers/gradle/gradle_parser.go

Lines changed: 147 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,20 @@ func (p *GradleParser) Parse(manifestFile string) ([]models.Package, error) {
2020
return nil, fmt.Errorf("failed to read manifest file: %w", err)
2121
}
2222

23-
lines := strings.Split(string(content), "\n")
23+
manifestContent := string(content)
2424

2525
// Extract variables
26-
variables := extractVariables(manifestFile, string(content))
26+
variables := extractVariables(manifestFile, manifestContent)
2727

2828
var packages []models.Package
2929

3030
// Parse main dependencies
31-
mainDeps := parseDependencies(string(content), lines, variables, false)
31+
mainDeps := parseDependencies(manifestContent, variables)
3232
for i := range mainDeps {
3333
mainDeps[i].FilePath = manifestFile
3434
}
3535
packages = append(packages, mainDeps...)
3636

37-
// Note: Buildscript dependencies are also parsed as main for simplicity
38-
3937
return packages, nil
4038
}
4139

@@ -99,73 +97,166 @@ func extractVariables(manifestFile, content string) map[string]string {
9997
return vars
10098
}
10199

100+
type dependencyStatement struct {
101+
Line int
102+
Text string
103+
}
104+
102105
// parseDependencies parses dependencies from the content
103-
func parseDependencies(content string, lines []string, variables map[string]string, isBuildscript bool) []models.Package {
106+
func parseDependencies(content string, variables map[string]string) []models.Package {
104107
var packages []models.Package
105108

106-
// Patterns for different dependency declarations
107-
patterns := []*regexp.Regexp{
108-
// String notation: implementation 'group:name:version'
109-
regexp.MustCompile(`(?i)(implementation|api|compile|runtime|testImplementation|testCompile|androidTestImplementation|classpath)\s*['"]([^'"]+)['"]`),
110-
regexp.MustCompile(`(?i)(implementation|api|compile|runtime|testImplementation|testCompile|androidTestImplementation|classpath)\s*\(\s*['"]([^'"]+)['"]\s*\)`),
111-
// Map notation: implementation group: 'g', name: 'n', version: 'v'
112-
regexp.MustCompile(`(?i)(implementation|api|compile|runtime|testImplementation|testCompile|androidTestImplementation|classpath)\s*group\s*:\s*['"]([^'"]+)['"]\s*,\s*name\s*:\s*['"]([^'"]+)['"]\s*,\s*version\s*:\s*['"]([^'"]+)['"]`),
113-
regexp.MustCompile(`(?i)(implementation|api|compile|runtime|testImplementation|testCompile|androidTestImplementation|classpath)\s*\(\s*group\s*:\s*['"]([^'"]+)['"]\s*,\s*name\s*:\s*['"]([^'"]+)['"]\s*,\s*version\s*:\s*['"]([^'"]+)['"]\s*\)`),
114-
}
115-
116-
depsLines := strings.Split(content, "\n")
117-
for _, line := range depsLines {
118-
line = strings.TrimSpace(line)
119-
if line == "" {
109+
statements := extractDependencyStatements(content)
110+
for _, stmt := range statements {
111+
for _, pkg := range parseDependencyStatement(stmt.Text, variables) {
112+
pkg.Locations = []models.Location{{Line: stmt.Line}}
113+
packages = append(packages, pkg)
114+
}
115+
}
116+
117+
return packages
118+
}
119+
120+
func extractDependencyStatements(content string) []dependencyStatement {
121+
startPattern := regexp.MustCompile(`(?i)\b(implementation|api|compile|compileOnly|runtime|runtimeOnly|testImplementation|testCompile|testRuntimeOnly|androidTestImplementation|annotationProcessor|classpath|kapt)\b`)
122+
var statements []dependencyStatement
123+
var buffer strings.Builder
124+
active := false
125+
startLine := 0
126+
127+
lines := strings.Split(content, "\n")
128+
for i, raw := range lines {
129+
line := strings.TrimSpace(raw)
130+
if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") {
120131
continue
121132
}
122133

123-
for _, pattern := range patterns {
124-
matches := pattern.FindStringSubmatch(line)
125-
if len(matches) > 0 {
126-
var group, name, version string
127-
if len(matches) == 3 {
128-
// String notation
129-
depStr := resolveVariables(matches[2], variables)
130-
parts := strings.Split(depStr, ":")
131-
if len(parts) >= 2 {
132-
group = parts[0]
133-
name = parts[1]
134-
if len(parts) > 2 {
135-
version = parts[2]
136-
}
137-
}
138-
} else if len(matches) == 5 {
139-
// Map notation
140-
group = resolveVariables(matches[2], variables)
141-
name = resolveVariables(matches[3], variables)
142-
version = resolveVariables(matches[4], variables)
134+
if !active {
135+
if startPattern.MatchString(line) {
136+
active = true
137+
startLine = i + 1
138+
buffer.Reset()
139+
buffer.WriteString(line)
140+
if dependencyStatementComplete(buffer.String()) {
141+
statements = append(statements, dependencyStatement{Line: startLine, Text: buffer.String()})
142+
active = false
143143
}
144+
}
145+
continue
146+
}
147+
148+
buffer.WriteString(" ")
149+
buffer.WriteString(line)
150+
if dependencyStatementComplete(buffer.String()) {
151+
statements = append(statements, dependencyStatement{Line: startLine, Text: buffer.String()})
152+
active = false
153+
}
154+
}
144155

145-
if group != "" && name != "" {
146-
// Handle version ranges and classifiers
147-
cleanVersion := cleanVersion(version)
148-
149-
// Find line number
150-
lineNum := findLineNumber(content, line)
151-
152-
packages = append(packages, models.Package{
153-
PackageManager: "gradle",
154-
PackageName: group + ":" + name,
155-
Version: cleanVersion,
156-
FilePath: "", // Will be set later
157-
Locations: []models.Location{
158-
{Line: lineNum},
159-
},
160-
})
156+
return statements
157+
}
158+
159+
func dependencyStatementComplete(statement string) bool {
160+
patterns := []*regexp.Regexp{
161+
regexp.MustCompile(`(?i)\b(implementation|api|compile|compileOnly|runtime|runtimeOnly|testImplementation|testCompile|testRuntimeOnly|androidTestImplementation|annotationProcessor|classpath|kapt)\s*['"]([^'"\)]+)['"]`),
162+
regexp.MustCompile(`(?i)\b(implementation|api|compile|compileOnly|runtime|runtimeOnly|testImplementation|testCompile|testRuntimeOnly|androidTestImplementation|annotationProcessor|classpath|kapt)\s*\(\s*['"]([^'"\)]+)['"]\s*\)`),
163+
regexp.MustCompile(`(?i)\b(implementation|api|compile|compileOnly|runtime|runtimeOnly|testImplementation|testCompile|testRuntimeOnly|androidTestImplementation|annotationProcessor|classpath|kapt)\s*group\s*[:=]\s*['"]([^'"]+)['"]\s*,\s*name\s*[:=]\s*['"]([^'"]+)['"]\s*,\s*version\s*[:=]\s*['"]([^'"]+)['"]`),
164+
regexp.MustCompile(`(?i)\b(implementation|api|compile|compileOnly|runtime|runtimeOnly|testImplementation|testCompile|testRuntimeOnly|androidTestImplementation|annotationProcessor|classpath|kapt)\s*\(\s*group\s*[:=]\s*['"]([^'"]+)['"]\s*,\s*name\s*[:=]\s*['"]([^'"]+)['"]\s*,\s*version\s*[:=]\s*['"]([^'"]+)['"]\s*\)`),
165+
regexp.MustCompile(`(?i)group\s*[:=]\s*['"]([^'"]+)['"].*name\s*[:=]\s*['"]([^'"]+)['"].*version\s*[:=]\s*['"]([^'"]+)['"]`),
166+
regexp.MustCompile(`(?i)group\s*[:=]\s*[^,\s]+.*name\s*[:=]\s*[^,\s]+.*version\s*[:=]\s*[^,\s]+`),
167+
}
168+
169+
for _, pattern := range patterns {
170+
if pattern.MatchString(statement) {
171+
return true
172+
}
173+
}
174+
175+
return false
176+
}
177+
178+
func parseDependencyStatement(statement string, variables map[string]string) []models.Package {
179+
var packages []models.Package
180+
181+
patterns := []*regexp.Regexp{
182+
regexp.MustCompile(`(?i)\b(implementation|api|compile|compileOnly|runtime|runtimeOnly|testImplementation|testCompile|testRuntimeOnly|androidTestImplementation|annotationProcessor|classpath|kapt)\s*['"]([^'"\)]+)['"]`),
183+
regexp.MustCompile(`(?i)\b(implementation|api|compile|compileOnly|runtime|runtimeOnly|testImplementation|testCompile|testRuntimeOnly|androidTestImplementation|annotationProcessor|classpath|kapt)\s*\(\s*['"]([^'"\)]+)['"]\s*\)`),
184+
regexp.MustCompile(`(?i)\b(implementation|api|compile|compileOnly|runtime|runtimeOnly|testImplementation|testCompile|testRuntimeOnly|androidTestImplementation|annotationProcessor|classpath|kapt)\s*group\s*[:=]\s*['"]([^'"]+)['"]\s*,\s*name\s*[:=]\s*['"]([^'"]+)['"]\s*,\s*version\s*[:=]\s*['"]([^'"]+)['"]`),
185+
regexp.MustCompile(`(?i)\b(implementation|api|compile|compileOnly|runtime|runtimeOnly|testImplementation|testCompile|testRuntimeOnly|androidTestImplementation|annotationProcessor|classpath|kapt)\s*\(\s*group\s*[:=]\s*['"]([^'"]+)['"]\s*,\s*name\s*[:=]\s*['"]([^'"]+)['"]\s*,\s*version\s*[:=]\s*['"]([^'"]+)['"]\s*\)`),
186+
}
187+
188+
for _, pattern := range patterns {
189+
matches := pattern.FindStringSubmatch(statement)
190+
if len(matches) > 0 {
191+
var group, name, version string
192+
if len(matches) == 3 {
193+
depStr := resolveVariables(matches[2], variables)
194+
parts := strings.Split(depStr, ":")
195+
if len(parts) >= 2 {
196+
group = parts[0]
197+
name = parts[1]
198+
if len(parts) > 2 {
199+
version = strings.Join(parts[2:], ":")
200+
}
161201
}
202+
} else if len(matches) == 5 {
203+
group = resolveVariables(matches[2], variables)
204+
name = resolveVariables(matches[3], variables)
205+
version = resolveVariables(matches[4], variables)
162206
}
207+
208+
if group != "" && name != "" {
209+
packages = append(packages, models.Package{
210+
PackageManager: "gradle",
211+
PackageName: group + ":" + name,
212+
Version: cleanVersion(version),
213+
FilePath: "",
214+
Locations: []models.Location{{}},
215+
})
216+
}
217+
}
218+
}
219+
220+
if len(packages) == 0 {
221+
if pkg := parseDependencyKeyValue(statement, variables); pkg != nil {
222+
packages = append(packages, *pkg)
163223
}
164224
}
165225

166226
return packages
167227
}
168228

229+
func parseDependencyKeyValue(statement string, variables map[string]string) *models.Package {
230+
fields := map[string]string{}
231+
232+
patterns := []*regexp.Regexp{
233+
regexp.MustCompile(`(?i)(group|name|version)\s*[:=]\s*['"]([^'"]+)['"]`),
234+
regexp.MustCompile(`(?i)(group|name|version)\s*[:=]\s*([A-Za-z_][A-Za-z0-9_]*)`),
235+
}
236+
237+
for _, pattern := range patterns {
238+
for _, match := range pattern.FindAllStringSubmatch(statement, -1) {
239+
if len(match) > 2 {
240+
key := strings.ToLower(match[1])
241+
value := match[2]
242+
fields[key] = resolveVariables(value, variables)
243+
}
244+
}
245+
}
246+
247+
if fields["group"] == "" || fields["name"] == "" {
248+
return nil
249+
}
250+
251+
return &models.Package{
252+
PackageManager: "gradle",
253+
PackageName: fields["group"] + ":" + fields["name"],
254+
Version: cleanVersion(fields["version"]),
255+
FilePath: "",
256+
Locations: []models.Location{{}},
257+
}
258+
}
259+
169260
// resolveVariables replaces ${var} or $var with values
170261
func resolveVariables(str string, variables map[string]string) string {
171262
// ${var}

0 commit comments

Comments
 (0)