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()) + } +}