From 577fbe56de5c15a7e391e038b32cfe6af46378df Mon Sep 17 00:00:00 2001 From: EdgarPsda Date: Fri, 13 Mar 2026 21:24:10 -0700 Subject: [PATCH] Add Python and Java project detection Support Python projects (requirements.txt, pyproject.toml, Pipfile, setup.py) with framework detection for Django, Flask, FastAPI, Scrapy, Tornado, and Starlette. Support Java projects (pom.xml, build.gradle, build.gradle.kts) with framework detection for Spring Boot, Quarkus, Micronaut, Jakarta EE, and Dropwizard. Co-Authored-By: Claude Sonnet 4.6 --- cli/detectors/detector.go | 3 +- cli/detectors/java.go | 205 ++++++++++++++++++++++++++++ cli/detectors/java_test.go | 145 ++++++++++++++++++++ cli/detectors/python.go | 251 +++++++++++++++++++++++++++++++++++ cli/detectors/python_test.go | 123 +++++++++++++++++ 5 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 cli/detectors/java.go create mode 100644 cli/detectors/java_test.go create mode 100644 cli/detectors/python.go create mode 100644 cli/detectors/python_test.go diff --git a/cli/detectors/detector.go b/cli/detectors/detector.go index c67e962..ef41a65 100644 --- a/cli/detectors/detector.go +++ b/cli/detectors/detector.go @@ -30,7 +30,8 @@ func DetectProject(dir string) (*ProjectInfo, error) { detectors := []Detector{ &NodeDetector{}, &GoDetector{}, - // Add more detectors here in the future + &PythonDetector{}, + &JavaDetector{}, } var bestMatch *ProjectInfo diff --git a/cli/detectors/java.go b/cli/detectors/java.go new file mode 100644 index 0000000..5b1eeb7 --- /dev/null +++ b/cli/detectors/java.go @@ -0,0 +1,205 @@ +// cli/detectors/java.go +package detectors + +import ( + "bufio" + "errors" + "os" + "path/filepath" + "strings" +) + +type JavaDetector struct { + confidence int +} + +func (d *JavaDetector) Detect(dir string) (*ProjectInfo, error) { + // Check for Java project files + packageFiles := []string{ + "pom.xml", + "build.gradle", + "build.gradle.kts", + } + + var foundFile string + for _, f := range packageFiles { + if fileExists(filepath.Join(dir, f)) { + foundFile = f + break + } + } + + if foundFile == "" { + d.confidence = 0 + return nil, errors.New("no Java project file found") + } + + deps := collectJavaDeps(filepath.Join(dir, foundFile), foundFile) + framework := detectJavaFramework(deps, dir, foundFile) + + d.confidence = 95 + + return &ProjectInfo{ + Language: "java", + Framework: framework, + PackageFile: foundFile, + RootDir: dir, + Dependencies: deps, + }, nil +} + +func (d *JavaDetector) Confidence() int { + return d.confidence +} + +func detectJavaFramework(deps []string, dir, packageFile string) string { + // Check dependencies for known frameworks + for _, dep := range deps { + lower := strings.ToLower(dep) + switch { + case strings.Contains(lower, "spring-boot"): + return "spring-boot" + case strings.Contains(lower, "quarkus"): + return "quarkus" + case strings.Contains(lower, "micronaut"): + return "micronaut" + case strings.Contains(lower, "jakarta.ee") || strings.Contains(lower, "javax.servlet"): + return "jakarta-ee" + case strings.Contains(lower, "dropwizard"): + return "dropwizard" + } + } + + // Also check the file content directly for Spring Boot parent POM + if packageFile == "pom.xml" { + data, err := os.ReadFile(filepath.Join(dir, packageFile)) + if err == nil { + content := strings.ToLower(string(data)) + if strings.Contains(content, "spring-boot-starter-parent") || + strings.Contains(content, "spring-boot-starter") { + return "spring-boot" + } + } + } + + return "" +} + +func collectJavaDeps(path, packageFile string) []string { + switch { + case packageFile == "pom.xml": + return parsePomXML(path) + case strings.HasPrefix(packageFile, "build.gradle"): + return parseGradle(path) + } + return nil +} + +// parsePomXML does a minimal parse to extract dependency artifact IDs from pom.xml +func parsePomXML(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var deps []string + scanner := bufio.NewScanner(f) + inDependency := false + var currentGroup, currentArtifact string + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.Contains(line, "") { + inDependency = true + currentGroup = "" + currentArtifact = "" + continue + } + + if strings.Contains(line, "") { + if inDependency && (currentGroup != "" || currentArtifact != "") { + dep := currentGroup + if currentArtifact != "" { + if dep != "" { + dep += ":" + } + dep += currentArtifact + } + deps = append(deps, dep) + } + inDependency = false + continue + } + + if inDependency { + if groupID := extractXMLValue(line, "groupId"); groupID != "" { + currentGroup = groupID + } + if artifactID := extractXMLValue(line, "artifactId"); artifactID != "" { + currentArtifact = artifactID + } + } + } + + return deps +} + +// parseGradle does a minimal parse to extract dependencies from build.gradle +func parseGradle(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var deps []string + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Match patterns like: implementation 'group:artifact:version' + // or: implementation "group:artifact:version" + for _, keyword := range []string{"implementation", "api", "compileOnly", "runtimeOnly", "testImplementation"} { + if strings.HasPrefix(line, keyword+" ") || strings.HasPrefix(line, keyword+"(") { + dep := extractGradleDep(line) + if dep != "" { + deps = append(deps, dep) + } + } + } + } + + return deps +} + +// extractXMLValue extracts the text content of a simple XML element +func extractXMLValue(line, tag string) string { + openTag := "<" + tag + ">" + closeTag := "" + + startIdx := strings.Index(line, openTag) + endIdx := strings.Index(line, closeTag) + + if startIdx >= 0 && endIdx > startIdx { + return strings.TrimSpace(line[startIdx+len(openTag) : endIdx]) + } + return "" +} + +// extractGradleDep extracts the dependency coordinate from a Gradle dependency line +func extractGradleDep(line string) string { + // Find the quoted string (single or double) + for _, quote := range []string{"'", "\""} { + start := strings.Index(line, quote) + if start >= 0 { + end := strings.Index(line[start+1:], quote) + if end >= 0 { + return line[start+1 : start+1+end] + } + } + } + return "" +} diff --git a/cli/detectors/java_test.go b/cli/detectors/java_test.go new file mode 100644 index 0000000..7255821 --- /dev/null +++ b/cli/detectors/java_test.go @@ -0,0 +1,145 @@ +package detectors + +import ( + "os" + "path/filepath" + "testing" +) + +func TestJavaDetector_Detect_SpringBoot_Maven(t *testing.T) { + dir := t.TempDir() + + pomXML := ` + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + + +` + + if err := os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(pomXML), 0o644); err != nil { + t.Fatalf("failed to write pom.xml: %v", err) + } + + d := &JavaDetector{} + info, err := d.Detect(dir) + if err != nil { + t.Fatalf("Detect returned error: %v", err) + } + + if info.Language != "java" { + t.Errorf("expected Language=java, got %s", info.Language) + } + if info.Framework != "spring-boot" { + t.Errorf("expected Framework=spring-boot, got %s", info.Framework) + } + if info.PackageFile != "pom.xml" { + t.Errorf("expected PackageFile=pom.xml, got %s", info.PackageFile) + } + if d.Confidence() <= 0 { + t.Errorf("expected confidence > 0, got %d", d.Confidence()) + } + if len(info.Dependencies) != 3 { + t.Errorf("expected 3 dependencies, got %d", len(info.Dependencies)) + } +} + +func TestJavaDetector_Detect_Gradle(t *testing.T) { + dir := t.TempDir() + + buildGradle := `plugins { + id 'java' + id 'org.springframework.boot' version '3.2.0' +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web:3.2.0' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa:3.2.0' + testImplementation 'org.springframework.boot:spring-boot-starter-test:3.2.0' + runtimeOnly 'org.postgresql:postgresql:42.7.0' +} +` + + if err := os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(buildGradle), 0o644); err != nil { + t.Fatalf("failed to write build.gradle: %v", err) + } + + d := &JavaDetector{} + info, err := d.Detect(dir) + if err != nil { + t.Fatalf("Detect returned error: %v", err) + } + + if info.Language != "java" { + t.Errorf("expected Language=java, got %s", info.Language) + } + if info.Framework != "spring-boot" { + t.Errorf("expected Framework=spring-boot, got %s", info.Framework) + } + if info.PackageFile != "build.gradle" { + t.Errorf("expected PackageFile=build.gradle, got %s", info.PackageFile) + } + if len(info.Dependencies) != 4 { + t.Errorf("expected 4 dependencies, got %d", len(info.Dependencies)) + } +} + +func TestJavaDetector_Detect_Quarkus(t *testing.T) { + dir := t.TempDir() + + pomXML := ` + + + + io.quarkus + quarkus-resteasy + + + io.quarkus + quarkus-hibernate-orm + + +` + + if err := os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(pomXML), 0o644); err != nil { + t.Fatalf("failed to write pom.xml: %v", err) + } + + d := &JavaDetector{} + info, err := d.Detect(dir) + if err != nil { + t.Fatalf("Detect returned error: %v", err) + } + + if info.Framework != "quarkus" { + t.Errorf("expected Framework=quarkus, got %s", info.Framework) + } +} + +func TestJavaDetector_Detect_NoProject(t *testing.T) { + dir := t.TempDir() + + d := &JavaDetector{} + _, err := d.Detect(dir) + if err == nil { + t.Error("expected error for empty directory, got nil") + } + if d.Confidence() != 0 { + t.Errorf("expected confidence=0, got %d", d.Confidence()) + } +} diff --git a/cli/detectors/python.go b/cli/detectors/python.go new file mode 100644 index 0000000..4b4bb39 --- /dev/null +++ b/cli/detectors/python.go @@ -0,0 +1,251 @@ +// cli/detectors/python.go +package detectors + +import ( + "bufio" + "errors" + "os" + "path/filepath" + "strings" +) + +type PythonDetector struct { + confidence int +} + +func (d *PythonDetector) Detect(dir string) (*ProjectInfo, error) { + // Check for Python project files in priority order + packageFiles := []string{ + "pyproject.toml", + "requirements.txt", + "Pipfile", + "setup.py", + "setup.cfg", + } + + var foundFile string + for _, f := range packageFiles { + if fileExists(filepath.Join(dir, f)) { + foundFile = f + break + } + } + + if foundFile == "" { + d.confidence = 0 + return nil, errors.New("no Python project file found") + } + + deps := collectPythonDeps(dir, foundFile) + framework := detectPythonFramework(deps) + + d.confidence = 95 + + return &ProjectInfo{ + Language: "python", + Framework: framework, + PackageFile: foundFile, + RootDir: dir, + Dependencies: deps, + }, nil +} + +func (d *PythonDetector) Confidence() int { + return d.confidence +} + +func detectPythonFramework(deps []string) string { + for _, dep := range deps { + lower := strings.ToLower(dep) + switch { + case lower == "django": + return "django" + case lower == "flask": + return "flask" + case lower == "fastapi": + return "fastapi" + case lower == "scrapy": + return "scrapy" + case lower == "tornado": + return "tornado" + case lower == "starlette": + return "starlette" + } + } + return "" +} + +func collectPythonDeps(dir, packageFile string) []string { + switch packageFile { + case "requirements.txt": + return parseRequirementsTxt(filepath.Join(dir, packageFile)) + case "pyproject.toml": + return parsePyprojectToml(filepath.Join(dir, packageFile)) + case "Pipfile": + return parsePipfile(filepath.Join(dir, packageFile)) + case "setup.py", "setup.cfg": + return parseSetupPy(filepath.Join(dir, packageFile)) + } + return nil +} + +// parseRequirementsTxt extracts package names from requirements.txt +func parseRequirementsTxt(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var deps []string + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines, comments, and options + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { + continue + } + + // Extract package name (before version specifiers) + name := extractPythonPackageName(line) + if name != "" { + deps = append(deps, name) + } + } + + return deps +} + +// parsePyprojectToml does a minimal parse to find dependency names +func parsePyprojectToml(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var deps []string + scanner := bufio.NewScanner(f) + inDependencies := false + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Detect [project.dependencies] or dependencies = [ + if line == "[project.dependencies]" || line == "dependencies = [" || + strings.HasPrefix(line, "[tool.poetry.dependencies]") { + inDependencies = true + continue + } + + // End of section + if inDependencies && (strings.HasPrefix(line, "[") || line == "]") { + inDependencies = false + continue + } + + if inDependencies && line != "" && !strings.HasPrefix(line, "#") { + // Handle both TOML formats: + // "django>=4.0", (PEP 621) + // django = "^4.0" (Poetry) + cleaned := strings.Trim(line, `",' `) + name := extractPythonPackageName(cleaned) + if name != "" { + deps = append(deps, name) + } + } + } + + return deps +} + +// parsePipfile does a minimal parse to find dependency names +func parsePipfile(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var deps []string + scanner := bufio.NewScanner(f) + inPackages := false + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if line == "[packages]" || line == "[dev-packages]" { + inPackages = true + continue + } + + // New section starts + if strings.HasPrefix(line, "[") { + inPackages = false + continue + } + + if inPackages && line != "" && !strings.HasPrefix(line, "#") { + // Format: package_name = "version" + parts := strings.SplitN(line, "=", 2) + if len(parts) >= 1 { + name := strings.TrimSpace(parts[0]) + if name != "" { + deps = append(deps, name) + } + } + } + } + + return deps +} + +// parseSetupPy does a minimal parse to find dependency names from setup.py/setup.cfg +func parseSetupPy(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var deps []string + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + // Look for common patterns in install_requires or setup.cfg + if strings.Contains(trimmed, "install_requires") { + continue + } + // Try to extract quoted package names + name := extractPythonPackageName(strings.Trim(trimmed, `",' `)) + if name != "" && !strings.Contains(name, "(") && !strings.Contains(name, "=") && + !strings.HasPrefix(name, "#") && !strings.HasPrefix(name, "[") { + deps = append(deps, name) + } + } + + return deps +} + +// extractPythonPackageName extracts the package name before version specifiers +func extractPythonPackageName(line string) string { + // Split on common version specifiers: >=, <=, ==, ~=, !=, >, <, ;, [ + for _, sep := range []string{">=", "<=", "==", "~=", "!=", ">", "<", ";", "["} { + if idx := strings.Index(line, sep); idx > 0 { + return strings.TrimSpace(line[:idx]) + } + } + + // Handle Poetry-style: name = "version" + if idx := strings.Index(line, "="); idx > 0 { + candidate := strings.TrimSpace(line[:idx]) + // Make sure it looks like a package name (no spaces, not a section header) + if !strings.Contains(candidate, " ") && !strings.HasPrefix(candidate, "[") { + return candidate + } + } + + return strings.TrimSpace(line) +} diff --git a/cli/detectors/python_test.go b/cli/detectors/python_test.go new file mode 100644 index 0000000..548a5bc --- /dev/null +++ b/cli/detectors/python_test.go @@ -0,0 +1,123 @@ +package detectors + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPythonDetector_Detect_Django(t *testing.T) { + dir := t.TempDir() + + reqTxt := `django>=4.2 +djangorestframework>=3.14 +celery>=5.3 +redis>=4.5 +` + + if err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte(reqTxt), 0o644); err != nil { + t.Fatalf("failed to write requirements.txt: %v", err) + } + + d := &PythonDetector{} + info, err := d.Detect(dir) + if err != nil { + t.Fatalf("Detect returned error: %v", err) + } + + if info.Language != "python" { + t.Errorf("expected Language=python, got %s", info.Language) + } + if info.Framework != "django" { + t.Errorf("expected Framework=django, got %s", info.Framework) + } + if info.PackageFile != "requirements.txt" { + t.Errorf("expected PackageFile=requirements.txt, got %s", info.PackageFile) + } + if d.Confidence() <= 0 { + t.Errorf("expected confidence > 0, got %d", d.Confidence()) + } + if len(info.Dependencies) != 4 { + t.Errorf("expected 4 dependencies, got %d", len(info.Dependencies)) + } +} + +func TestPythonDetector_Detect_FastAPI_Pyproject(t *testing.T) { + dir := t.TempDir() + + pyproject := `[project] +name = "my-api" +version = "1.0.0" + +[project.dependencies] +"fastapi>=0.100", +"uvicorn>=0.23", +"sqlalchemy>=2.0", +` + + if err := os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(pyproject), 0o644); err != nil { + t.Fatalf("failed to write pyproject.toml: %v", err) + } + + d := &PythonDetector{} + info, err := d.Detect(dir) + if err != nil { + t.Fatalf("Detect returned error: %v", err) + } + + if info.Language != "python" { + t.Errorf("expected Language=python, got %s", info.Language) + } + if info.Framework != "fastapi" { + t.Errorf("expected Framework=fastapi, got %s", info.Framework) + } + if info.PackageFile != "pyproject.toml" { + t.Errorf("expected PackageFile=pyproject.toml, got %s", info.PackageFile) + } +} + +func TestPythonDetector_Detect_Flask_Pipfile(t *testing.T) { + dir := t.TempDir() + + pipfile := `[packages] +flask = ">=2.3" +gunicorn = "*" +psycopg2 = ">=2.9" + +[dev-packages] +pytest = "*" +` + + if err := os.WriteFile(filepath.Join(dir, "Pipfile"), []byte(pipfile), 0o644); err != nil { + t.Fatalf("failed to write Pipfile: %v", err) + } + + d := &PythonDetector{} + info, err := d.Detect(dir) + if err != nil { + t.Fatalf("Detect returned error: %v", err) + } + + if info.Language != "python" { + t.Errorf("expected Language=python, got %s", info.Language) + } + if info.Framework != "flask" { + t.Errorf("expected Framework=flask, got %s", info.Framework) + } + if info.PackageFile != "Pipfile" { + t.Errorf("expected PackageFile=Pipfile, got %s", info.PackageFile) + } +} + +func TestPythonDetector_Detect_NoProject(t *testing.T) { + dir := t.TempDir() + + d := &PythonDetector{} + _, err := d.Detect(dir) + if err == nil { + t.Error("expected error for empty directory, got nil") + } + if d.Confidence() != 0 { + t.Errorf("expected confidence=0, got %d", d.Confidence()) + } +}