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 := "" + tag + ">"
+
+ 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())
+ }
+}