diff --git a/.cursor/rules/cursor.mdc b/.cursor/rules/cursor.mdc index f1055f4b..49ebdea3 100644 --- a/.cursor/rules/cursor.mdc +++ b/.cursor/rules/cursor.mdc @@ -12,6 +12,7 @@ alwaysApply: true - run go build after each code modification to see if app compiles - remove dead unused code - look for constants like file permissons in `constants` folder +- run go tests if you modified tests files ## Code Style Guidelines - **Imports**: Standard lib first, external packages second, internal last diff --git a/cmd/init.go b/cmd/init.go index 1d5a1939..41e93365 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -47,22 +47,97 @@ var initCmd = &cobra.Command{ if cliLocalMode { fmt.Println() - fmt.Println("ℹ️ No project token was specified, fetching codacy default configurations") - noTools := []domain.Tool{} - err := createConfigurationFiles(noTools, cliLocalMode) + fmt.Println("ℹ️ No project token was specified, detecting languages and configuring tools...") + + // Create language detector and scan the project + detector := utils.NewLanguageDetector() + languages, err := detector.DetectLanguages(".") + if err != nil { + log.Fatalf("Failed to detect languages: %v", err) + } + + // Get all available tools from the API + availableTools, err := codacyclient.GetToolsVersions() + if err != nil { + log.Fatalf("Failed to get tools versions: %v", err) + } + + // Map tools by name for easier lookup + toolsByName := make(map[string]domain.Tool) + for _, tool := range availableTools { + toolsByName[strings.ToLower(tool.Name)] = tool + } + + // Enable tools based on detected languages + var enabledTools []domain.Tool + toolsEnabled := make(map[string]bool) + + // Always enable Trivy for security scanning + if trivyTool, ok := toolsByName["trivy"]; ok { + enabledTools = append(enabledTools, trivyTool) + toolsEnabled[trivyTool.Uuid] = true + } + + // Enable tools based on detected languages + for langName := range languages { + for _, tool := range availableTools { + if !toolsEnabled[tool.Uuid] { + for _, supportedLang := range tool.Languages { + if strings.EqualFold(langName, supportedLang) { + enabledTools = append(enabledTools, tool) + toolsEnabled[tool.Uuid] = true + break + } + } + } + } + } + + // Always enable Lizard for complexity analysis if any supported language is detected + if shouldEnableLizard(languages) { + if lizardTool, ok := toolsByName["lizard"]; ok && !toolsEnabled[lizardTool.Uuid] { + enabledTools = append(enabledTools, lizardTool) + toolsEnabled[lizardTool.Uuid] = true + } + } + + // Create configuration files + err = createConfigurationFiles(enabledTools, cliLocalMode) if err != nil { log.Fatal(err) } - // Create default configuration files - if err := buildDefaultConfigurationFiles(toolsConfigDir); err != nil { + + // Create default configuration files for enabled tools + if err := buildDefaultConfigurationFiles(toolsConfigDir, enabledTools); err != nil { log.Fatal(err) } + + // Print summary of detected languages and enabled tools + fmt.Println("\nDetected languages:") + for langName, info := range languages { + fmt.Printf(" - %s (%d files)\n", langName, len(info.Files)) + } + + fmt.Println("\nEnabled tools:") + // Create a map of supported tool UUIDs for quick lookup + supportedTools := make(map[string]bool) + for _, uuid := range AvailableTools { + supportedTools[uuid] = true + } + + // Only show tools that are both enabled and supported by the CLI + for _, tool := range enabledTools { + if supportedTools[tool.Uuid] { + fmt.Printf(" - %s@%s\n", tool.Name, tool.Version) + } + } } else { err := buildRepositoryConfigurationFiles(initFlags.ApiToken) if err != nil { log.Fatal(err) } } + createGitIgnoreFile() fmt.Println() fmt.Println("✅ Successfully initialized Codacy configuration!") @@ -74,6 +149,45 @@ var initCmd = &cobra.Command{ }, } +// shouldEnableLizard checks if Lizard should be enabled based on detected languages +func shouldEnableLizard(languages map[string]*utils.LanguageInfo) bool { + lizardSupportedLangs := map[string]bool{ + "C": true, "C++": true, "Java": true, "C#": true, + "JavaScript": true, "TypeScript": true, "Python": true, + "Ruby": true, "PHP": true, "Scala": true, "Go": true, + "Rust": true, "Kotlin": true, "Swift": true, + } + + for lang := range languages { + if lizardSupportedLangs[lang] { + return true + } + } + return false +} + +// toolNameFromUUID returns the short name for a tool UUID +func toolNameFromUUID(uuid string) string { + switch uuid { + case ESLint: + return "eslint" + case Trivy: + return "trivy" + case PyLint: + return "pylint" + case PMD: + return "pmd" + case DartAnalyzer: + return "dartanalyzer" + case Semgrep: + return "semgrep" + case Lizard: + return "lizard" + default: + return "unknown" + } +} + func createGitIgnoreFile() error { gitIgnorePath := filepath.Join(config.Config.LocalCodacyDirectory(), ".gitignore") gitIgnoreFile, err := os.Create(gitIgnorePath) @@ -427,14 +541,14 @@ func createLizardConfigFile(toolsConfigDir string, patternConfiguration []domain return nil } -// buildDefaultConfigurationFiles creates default configuration files for all tools -func buildDefaultConfigurationFiles(toolsConfigDir string) error { - for _, tool := range AvailableTools { - patternsConfig, err := codacyclient.GetDefaultToolPatternsConfig(initFlags, tool) +// buildDefaultConfigurationFiles creates default configuration files for enabled tools +func buildDefaultConfigurationFiles(toolsConfigDir string, enabledTools []domain.Tool) error { + for _, tool := range enabledTools { + patternsConfig, err := codacyclient.GetDefaultToolPatternsConfig(initFlags, tool.Uuid) if err != nil { return fmt.Errorf("failed to get default tool patterns config: %w", err) } - switch tool { + switch tool.Uuid { case ESLint: if err := tools.CreateEslintConfig(toolsConfigDir, patternsConfig); err != nil { return fmt.Errorf("failed to create eslint config file: %v", err) diff --git a/cmd/init_test.go b/cmd/init_test.go index 417eb926..7323c551 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -2,8 +2,8 @@ package cmd import ( "codacy/cli-v2/config" + "codacy/cli-v2/constants" "codacy/cli-v2/domain" - "codacy/cli-v2/utils" "os" "path/filepath" "testing" @@ -96,54 +96,18 @@ func TestConfigFileTemplate(t *testing.T) { "pmd", }, }, - { - name: "all tools enabled", - tools: []domain.Tool{ - { - Uuid: ESLint, - Name: "eslint", - Version: "9.4.0", - }, - { - Uuid: Trivy, - Name: "trivy", - Version: "0.60.0", - }, - { - Uuid: PyLint, - Name: "pylint", - Version: "3.4.0", - }, - { - Uuid: PMD, - Name: "pmd", - Version: "6.56.0", - }, - }, - expected: []string{ - "node@22.2.0", - "python@3.11.11", - "eslint@9.4.0", - "trivy@0.60.0", - "pylint@3.4.0", - "pmd@6.56.0", - }, - notExpected: []string{}, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := configFileTemplate(tt.tools) - // Check that expected strings are present for _, exp := range tt.expected { - assert.Contains(t, result, exp, "Config file should contain %s", exp) + assert.Contains(t, result, exp) } - // Check that not-expected strings are absent for _, notExp := range tt.notExpected { - assert.NotContains(t, result, notExp, "Config file should not contain %s", notExp) + assert.NotContains(t, result, notExp) } }) } @@ -163,7 +127,7 @@ func TestCleanConfigDirectory(t *testing.T) { for _, file := range testFiles { filePath := filepath.Join(tempDir, file) - err := os.WriteFile(filePath, []byte("test content"), utils.DefaultFilePerms) + err := os.WriteFile(filePath, []byte("test content"), constants.DefaultFilePerms) assert.NoError(t, err, "Failed to create test file: %s", filePath) } @@ -182,65 +146,140 @@ func TestCleanConfigDirectory(t *testing.T) { assert.Equal(t, 0, len(files), "Expected 0 files after cleaning, got %d", len(files)) } -func TestInitCommand_NoToken(t *testing.T) { +func TestInitCommand_LanguageDetection(t *testing.T) { + // Create a temporary directory for the test tempDir := t.TempDir() originalWD, err := os.Getwd() assert.NoError(t, err, "Failed to get current working directory") defer os.Chdir(originalWD) - // Use the real plugins/tools/semgrep/rules.yaml file - rulesPath := filepath.Join("plugins", "tools", "semgrep", "rules.yaml") - if _, err := os.Stat(rulesPath); os.IsNotExist(err) { - t.Skip("plugins/tools/semgrep/rules.yaml not found; skipping test") + // Change to the temp directory + err = os.Chdir(tempDir) + assert.NoError(t, err) + + // Set up config with the correct paths + config.Config = *config.NewConfigType(tempDir, filepath.Join(tempDir, ".codacy"), filepath.Join(tempDir, ".cache", "codacy")) + + // Create test files for different languages + testFiles := map[string]string{ + "src/main.go": "package main", + "src/app.js": "console.log('hello')", + "src/lib.py": "print('hello')", + "src/Main.java": "class Main {}", + "src/styles.css": "body { margin: 0; }", + "src/config.json": "{}", + "src/Dockerfile": "FROM ubuntu", + "src/app.dart": "void main() {}", + "src/test.rs": "fn main() {}", + "vendor/ignore.js": "// should be ignored", + "node_modules/pkg.js": "// should be ignored", + ".git/config": "// should be ignored", } - // Change to the temp directory to simulate a new project - err = os.Chdir(tempDir) - assert.NoError(t, err, "Failed to change working directory to tempDir") + // Create the files in the temporary directory + for path, content := range testFiles { + fullPath := filepath.Join(tempDir, path) + err := os.MkdirAll(filepath.Dir(fullPath), constants.DefaultDirPerms) + assert.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), constants.DefaultFilePerms) + assert.NoError(t, err) + } - // Simulate running init with no token + // Create necessary directories + err = config.Config.CreateLocalCodacyDir() + assert.NoError(t, err) + toolsConfigDir := config.Config.ToolsConfigDirectory() + err = os.MkdirAll(toolsConfigDir, constants.DefaultDirPerms) + assert.NoError(t, err) + + // Reset initFlags to simulate local mode initFlags.ApiToken = "" initFlags.Provider = "" initFlags.Organization = "" initFlags.Repository = "" - // Call the Run logic from initCmd - if err := config.Config.CreateLocalCodacyDir(); err != nil { - t.Fatalf("Failed to create local codacy directory: %v", err) - } + // Run the init command + initCmd.Run(initCmd, []string{}) - toolsConfigDir := config.Config.ToolsConfigDirectory() - if err := os.MkdirAll(toolsConfigDir, utils.DefaultFilePerms); err != nil { - t.Fatalf("Failed to create tools-configs directory: %v", err) - } + // Verify that the configuration files were created + codacyYaml := filepath.Join(config.Config.LocalCodacyDirectory(), "codacy.yaml") + assert.FileExists(t, codacyYaml) - cliLocalMode := len(initFlags.ApiToken) == 0 - if cliLocalMode { - noTools := []domain.Tool{} - err := createConfigurationFiles(noTools, cliLocalMode) - assert.NoError(t, err, "createConfigurationFiles should not return an error") - if err := buildDefaultConfigurationFiles(toolsConfigDir); err != nil { - t.Fatalf("Failed to build default configuration files: %v", err) - } - } + // Read and verify the codacy.yaml content + content, err := os.ReadFile(codacyYaml) + assert.NoError(t, err) + contentStr := string(content) - // Assert that the expected config files are created - codacyDir := config.Config.LocalCodacyDirectory() - expectedFiles := []string{ - "tools-configs/eslint.config.mjs", - "tools-configs/trivy.yaml", - "tools-configs/ruleset.xml", - "tools-configs/pylint.rc", - "tools-configs/analysis_options.yaml", - "tools-configs/semgrep.yaml", - "tools-configs/lizard.yaml", - "codacy.yaml", - "cli-config.yaml", + // Check that appropriate tools are enabled based on detected languages + assert.Contains(t, contentStr, "eslint@", "ESLint should be enabled for JavaScript") + assert.Contains(t, contentStr, "pylint@", "PyLint should be enabled for Python") + assert.Contains(t, contentStr, "semgrep@", "Semgrep should be enabled for Go and Rust") + assert.Contains(t, contentStr, "dartanalyzer@", "DartAnalyzer should be enabled for Dart") + assert.Contains(t, contentStr, "trivy@", "Trivy should always be enabled") + assert.Contains(t, contentStr, "lizard@", "Lizard should be enabled when supported languages are detected") + + // Verify that tool configuration files were created + expectedConfigFiles := []string{ + "eslint.config.mjs", + "pylint.rc", + "trivy.yaml", + "semgrep.yaml", + "analysis_options.yaml", // for dartanalyzer + "lizard.yaml", } - for _, file := range expectedFiles { - filePath := filepath.Join(codacyDir, file) - _, err := os.Stat(filePath) - assert.NoError(t, err, "Expected config file %s to be created", file) + for _, configFile := range expectedConfigFiles { + assert.FileExists(t, filepath.Join(toolsConfigDir, configFile)) } } + +func TestInitCommand_NoLanguagesDetected(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + originalWD, err := os.Getwd() + assert.NoError(t, err, "Failed to get current working directory") + defer os.Chdir(originalWD) + + // Change to the temp directory (empty, no source files) + err = os.Chdir(tempDir) + assert.NoError(t, err) + + // Set up config with the correct paths + config.Config = *config.NewConfigType(tempDir, filepath.Join(tempDir, ".codacy"), filepath.Join(tempDir, ".cache", "codacy")) + + // Create necessary directories + err = config.Config.CreateLocalCodacyDir() + assert.NoError(t, err) + toolsConfigDir := config.Config.ToolsConfigDirectory() + err = os.MkdirAll(toolsConfigDir, constants.DefaultDirPerms) + assert.NoError(t, err) + + // Reset initFlags to simulate local mode + initFlags.ApiToken = "" + initFlags.Provider = "" + initFlags.Organization = "" + initFlags.Repository = "" + + // Run the init command + initCmd.Run(initCmd, []string{}) + + // Verify that the configuration files were created + codacyYaml := filepath.Join(config.Config.LocalCodacyDirectory(), "codacy.yaml") + assert.FileExists(t, codacyYaml) + + // Read and verify the codacy.yaml content + content, err := os.ReadFile(codacyYaml) + assert.NoError(t, err) + contentStr := string(content) + + // Check that only Trivy is enabled when no languages are detected + assert.Contains(t, contentStr, "trivy@", "Trivy should always be enabled") + assert.NotContains(t, contentStr, "eslint@", "ESLint should not be enabled") + assert.NotContains(t, contentStr, "pylint@", "PyLint should not be enabled") + assert.NotContains(t, contentStr, "semgrep@", "Semgrep should not be enabled") + assert.NotContains(t, contentStr, "dartanalyzer@", "DartAnalyzer should not be enabled") + assert.NotContains(t, contentStr, "lizard@", "Lizard should not be enabled") + + // Verify that only Trivy configuration file was created + assert.FileExists(t, filepath.Join(toolsConfigDir, "trivy.yaml")) +} diff --git a/domain/tool.go b/domain/tool.go index e00e0ecc..5335d80e 100644 --- a/domain/tool.go +++ b/domain/tool.go @@ -7,10 +7,11 @@ type ToolsResponse struct { // Tool represents a tool in the Codacy API type Tool struct { - Uuid string `json:"uuid"` - Name string `json:"name"` - Version string `json:"version"` - Settings struct { + Uuid string `json:"uuid"` + Name string `json:"name"` + Version string `json:"version"` + Languages []string `json:"languages"` + Settings struct { Enabled bool `json:"isEnabled"` HasConfigurationFile bool `json:"hasConfigurationFile"` UsesConfigurationFile bool `json:"usesConfigurationFile"` diff --git a/go.mod b/go.mod index d76c23ba..5f89d18e 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/term v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 0914519f..6f17bfc9 100644 --- a/go.sum +++ b/go.sum @@ -122,6 +122,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -132,6 +134,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/integration-tests/init-without-token/src_test/file_for_test.dart b/integration-tests/init-without-token/src_test/file_for_test.dart new file mode 100644 index 00000000..e69de29b diff --git a/integration-tests/init-without-token/src_test/file_for_tests.py b/integration-tests/init-without-token/src_test/file_for_tests.py new file mode 100644 index 00000000..e69de29b diff --git a/utils/language_detector.go b/utils/language_detector.go new file mode 100644 index 00000000..b2c85f98 --- /dev/null +++ b/utils/language_detector.go @@ -0,0 +1,157 @@ +package utils + +import ( + "os" + "path/filepath" + "strings" + + gitignore "github.com/sabhiram/go-gitignore" +) + +// LanguageInfo represents information about a detected language +type LanguageInfo struct { + Name string + Extensions []string + Files []string +} + +// LanguageDetector handles language detection in a project +type LanguageDetector struct { + // Map of language name to language info + languages map[string]*LanguageInfo + // Map of extension to language name + extensionMap map[string]string + // GitIgnore handler + gitIgnore *gitignore.GitIgnore +} + +// NewLanguageDetector creates a new language detector with predefined language mappings +func NewLanguageDetector() *LanguageDetector { + detector := &LanguageDetector{ + languages: make(map[string]*LanguageInfo), + extensionMap: make(map[string]string), + } + + // Initialize with known languages and their extensions + detector.addLanguage("JavaScript", []string{".js", ".jsx", ".mjs"}) + detector.addLanguage("TypeScript", []string{".ts", ".tsx"}) + detector.addLanguage("Python", []string{".py", ".pyi", ".pyw"}) + detector.addLanguage("Java", []string{".java"}) + detector.addLanguage("Go", []string{".go"}) + detector.addLanguage("Ruby", []string{".rb", ".rake", ".gemspec"}) + detector.addLanguage("PHP", []string{".php"}) + detector.addLanguage("C", []string{".c", ".h"}) + detector.addLanguage("C++", []string{".cpp", ".hpp", ".cc", ".hh"}) + detector.addLanguage("C#", []string{".cs"}) + detector.addLanguage("Dart", []string{".dart"}) + detector.addLanguage("Kotlin", []string{".kt", ".kts"}) + detector.addLanguage("Swift", []string{".swift"}) + detector.addLanguage("Scala", []string{".scala", ".sc"}) + detector.addLanguage("Rust", []string{".rs"}) + detector.addLanguage("Shell", []string{".sh", ".bash"}) + detector.addLanguage("HTML", []string{".html", ".htm"}) + detector.addLanguage("CSS", []string{".css", ".scss", ".sass", ".less"}) + detector.addLanguage("XML", []string{".xml"}) + detector.addLanguage("JSON", []string{".json"}) + detector.addLanguage("YAML", []string{".yml", ".yaml"}) + detector.addLanguage("Markdown", []string{".md", ".markdown"}) + detector.addLanguage("Dockerfile", []string{"Dockerfile"}) + detector.addLanguage("Terraform", []string{".tf", ".tfvars"}) + + return detector +} + +// addLanguage adds a language and its extensions to the detector +func (d *LanguageDetector) addLanguage(name string, extensions []string) { + d.languages[name] = &LanguageInfo{ + Name: name, + Extensions: extensions, + Files: make([]string, 0), + } + for _, ext := range extensions { + d.extensionMap[ext] = name + } +} + +// DetectLanguages scans a directory and detects programming languages used +func (d *LanguageDetector) DetectLanguages(rootDir string) (map[string]*LanguageInfo, error) { + // Initialize gitignore handler + gitIgnorePath := filepath.Join(rootDir, ".gitignore") + var err error + d.gitIgnore, err = gitignore.CompileIgnoreFile(gitIgnorePath) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + // Reset files for each language + for _, lang := range d.languages { + lang.Files = make([]string, 0) + } + + // Walk through the directory + err = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip .git directory + if info.IsDir() && info.Name() == ".git" { + return filepath.SkipDir + } + + // Skip vendor directories + if info.IsDir() && (info.Name() == "vendor" || info.Name() == "node_modules") { + return filepath.SkipDir + } + + // Get relative path for gitignore matching + relPath, err := filepath.Rel(rootDir, path) + if err != nil { + return err + } + + // Skip files from .gitignore + if d.gitIgnore != nil && d.gitIgnore.MatchesPath(relPath) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Get file extension + ext := strings.ToLower(filepath.Ext(path)) + if ext == "" { + // For files without extension, use the filename (e.g., "Dockerfile") + ext = strings.ToLower(info.Name()) + } + + // Check if extension is mapped to a language + if langName, ok := d.extensionMap[ext]; ok { + // Add file to language info + if lang, exists := d.languages[langName]; exists { + lang.Files = append(lang.Files, relPath) + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + // Filter out languages with no files + result := make(map[string]*LanguageInfo) + for name, lang := range d.languages { + if len(lang.Files) > 0 { + result[name] = lang + } + } + + return result, nil +} diff --git a/utils/language_detector_test.go b/utils/language_detector_test.go new file mode 100644 index 00000000..0434bd5f --- /dev/null +++ b/utils/language_detector_test.go @@ -0,0 +1,213 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewLanguageDetector(t *testing.T) { + detector := NewLanguageDetector() + + // Test that the detector is initialized with known languages + expectedLanguages := []string{ + "JavaScript", "TypeScript", "Python", "Java", "Go", + "Ruby", "PHP", "C", "C++", "C#", "Dart", "Kotlin", + "Swift", "Scala", "Rust", "Shell", "HTML", "CSS", + "XML", "JSON", "YAML", "Markdown", "Terraform", + } + + for _, lang := range expectedLanguages { + _, exists := detector.languages[lang] + assert.True(t, exists, "Expected language %s to be initialized", lang) + } + + // Test that extension mappings are correctly set up + testCases := []struct { + ext string + language string + }{ + {".js", "JavaScript"}, + {".py", "Python"}, + {".java", "Java"}, + {".go", "Go"}, + {".rb", "Ruby"}, + {".php", "PHP"}, + {".ts", "TypeScript"}, + {".tsx", "TypeScript"}, + {".jsx", "JavaScript"}, + {".cpp", "C++"}, + {".cs", "C#"}, + {".dart", "Dart"}, + {".kt", "Kotlin"}, + {".swift", "Swift"}, + {".scala", "Scala"}, + {".rs", "Rust"}, + {".tf", "Terraform"}, + } + + for _, tc := range testCases { + lang, exists := detector.extensionMap[tc.ext] + assert.True(t, exists, "Expected extension %s to be mapped", tc.ext) + assert.Equal(t, tc.language, lang, "Expected extension %s to map to language %s", tc.ext, tc.language) + } +} + +func TestDetectLanguages(t *testing.T) { + // Create a temporary directory for test files + tempDir := t.TempDir() + + // Create test files + testFiles := map[string]string{ + "src/main.go": "package main", + "src/app.js": "console.log('hello')", + "src/lib.py": "print('hello')", + "src/Main.java": "class Main {}", + "src/styles.css": "body { margin: 0; }", + "src/config.json": "{}", + "src/Dockerfile": "FROM ubuntu", + "src/app.dart": "void main() {}", + "src/test.rs": "fn main() {}", + "vendor/ignore.js": "// should be ignored", + "node_modules/pkg.js": "// should be ignored", + ".git/config": "// should be ignored", + } + + // Create the files in the temporary directory + for path, content := range testFiles { + fullPath := filepath.Join(tempDir, path) + err := os.MkdirAll(filepath.Dir(fullPath), 0755) + assert.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), 0644) + assert.NoError(t, err) + } + + // Create and run the detector + detector := NewLanguageDetector() + languages, err := detector.DetectLanguages(tempDir) + assert.NoError(t, err) + + // Verify detected languages + expectedLanguages := map[string][]string{ + "Go": {"src/main.go"}, + "JavaScript": {"src/app.js"}, + "Python": {"src/lib.py"}, + "Java": {"src/Main.java"}, + "CSS": {"src/styles.css"}, + "JSON": {"src/config.json"}, + "Dart": {"src/app.dart"}, + "Rust": {"src/test.rs"}, + } + + // Check that we found all expected languages + assert.Equal(t, len(expectedLanguages), len(languages)) + + // Check each language's files + for langName, expectedFiles := range expectedLanguages { + lang, exists := languages[langName] + assert.True(t, exists, "Language %s should be detected", langName) + if exists { + assert.ElementsMatch(t, expectedFiles, lang.Files, "Files for language %s should match", langName) + } + } + + // Verify that ignored directories were actually ignored + for langName, lang := range languages { + for _, file := range lang.Files { + assert.NotContains(t, file, "vendor/", "Language %s should not contain files from vendor/", langName) + assert.NotContains(t, file, "node_modules/", "Language %s should not contain files from node_modules/", langName) + assert.NotContains(t, file, ".git/", "Language %s should not contain files from .git/", langName) + } + } +} + +func TestDetectLanguages_EmptyDirectory(t *testing.T) { + tempDir := t.TempDir() + detector := NewLanguageDetector() + languages, err := detector.DetectLanguages(tempDir) + assert.NoError(t, err) + assert.Empty(t, languages, "Empty directory should not detect any languages") +} + +func TestDetectLanguages_NonExistentDirectory(t *testing.T) { + detector := NewLanguageDetector() + languages, err := detector.DetectLanguages("/path/that/does/not/exist") + assert.Error(t, err) + assert.Nil(t, languages) +} + +func TestDetectLanguages_WithGitignore(t *testing.T) { + // Create a temporary directory for test files + tempDir := t.TempDir() + + // Create a .gitignore file + gitignoreContent := ` +# Ignore build directory +build/ +# Ignore all .log files +*.log +# Ignore specific file +ignored.js +# Ignore test files +**/*.test.js +` + err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0644) + assert.NoError(t, err) + + // Create test files + testFiles := map[string]string{ + "src/app.js": "console.log('hello')", + "build/output.js": "// should be ignored", + "debug.log": "// should be ignored", + "ignored.js": "// should be ignored", + "src/not-ignored.js": "// should be included", + "src/test/app.test.js": "// should be ignored", + "src/lib.py": "print('hello')", + "src/Main.java": "class Main {}", + } + + // Create the files in the temporary directory + for path, content := range testFiles { + fullPath := filepath.Join(tempDir, path) + err := os.MkdirAll(filepath.Dir(fullPath), 0755) + assert.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), 0644) + assert.NoError(t, err) + } + + // Create and run the detector + detector := NewLanguageDetector() + languages, err := detector.DetectLanguages(tempDir) + assert.NoError(t, err) + + // Verify detected languages and files + expectedLanguages := map[string][]string{ + "JavaScript": {"src/app.js", "src/not-ignored.js"}, + "Python": {"src/lib.py"}, + "Java": {"src/Main.java"}, + } + + // Check that we found all expected languages + assert.Equal(t, len(expectedLanguages), len(languages)) + + // Check each language's files + for langName, expectedFiles := range expectedLanguages { + lang, exists := languages[langName] + assert.True(t, exists, "Language %s should be detected", langName) + if exists { + assert.ElementsMatch(t, expectedFiles, lang.Files, "Files for language %s should match", langName) + } + } + + // Verify that ignored files were actually ignored + for _, lang := range languages { + for _, file := range lang.Files { + assert.NotContains(t, file, "build/", "Should not contain files from build/") + assert.NotContains(t, file, ".log", "Should not contain .log files") + assert.NotEqual(t, file, "ignored.js", "Should not contain the ignored.js file") + assert.NotContains(t, file, ".test.js", "Should not contain test files") + } + } +}