diff --git a/.codacy/codacy.yaml b/.codacy/codacy.yaml index 53350bdc..9ab2f90f 100644 --- a/.codacy/codacy.yaml +++ b/.codacy/codacy.yaml @@ -4,4 +4,5 @@ runtimes: tools: - eslint@9.3.0 - trivy@0.59.1 + - pmd@7.12.0 - pylint@3.3.6 diff --git a/.cursor/rules/cursor.mdc b/.cursor/rules/cursor.mdc new file mode 100644 index 00000000..6c00a0c5 --- /dev/null +++ b/.cursor/rules/cursor.mdc @@ -0,0 +1,22 @@ +--- +description: +globs: +alwaysApply: true +--- + +# Your rule content + +## Code Style Guidelines +- **Imports**: Standard lib first, external packages second, internal last +- **Naming**: PascalCase for exported (public), camelCase for unexported (private) +- **Error handling**: Return errors as last value, check with `if err != nil` +- **Testing**: Use testify/assert package for assertions +- **Package organization**: Keep related functionality in dedicated packages +- **Documentation**: Document all exported functions, types, and packages +- **Commit messages**: Start with verb, be concise and descriptive + +## Project Structure +- `cmd/`: CLI command implementations +- `config/`: Configuration handling +- `tools/`: Tool-specific implementations +- `utils/`: Utility functions \ No newline at end of file diff --git a/cmd/analyze.go b/cmd/analyze.go index 9cc5ad4d..56108e0e 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -20,6 +20,7 @@ var outputFormat string var sarifPath string var commitUuid string var projectToken string +var pmdRulesetFile string type Sarif struct { Runs []struct { @@ -95,6 +96,7 @@ func init() { analyzeCmd.Flags().StringVarP(&toolToAnalyze, "tool", "t", "", "Which tool to run analysis with") analyzeCmd.Flags().StringVar(&outputFormat, "format", "", "Output format (use 'sarif' for SARIF format)") analyzeCmd.Flags().BoolVar(&autoFix, "fix", false, "Apply auto fix to your issues when available") + analyzeCmd.Flags().StringVar(&pmdRulesetFile, "rulesets", "", "Path to PMD ruleset file") rootCmd.AddCommand(analyzeCmd) } @@ -203,6 +205,16 @@ func runTrivyAnalysis(workDirectory string, pathsToCheck []string, outputFile st } } +func runPmdAnalysis(workDirectory string, pathsToCheck []string, outputFile string, outputFormat string) { + pmd := config.Config.Tools()["pmd"] + pmdBinary := pmd.Binaries["pmd"] + + err := tools.RunPmd(workDirectory, pmdBinary, pathsToCheck, outputFile, outputFormat, pmdRulesetFile) + if err != nil { + log.Fatalf("Error running PMD: %v", err) + } +} + func runPylintAnalysis(workDirectory string, pathsToCheck []string, outputFile string, outputFormat string) { pylint := config.Config.Tools()["pylint"] @@ -235,6 +247,8 @@ var analyzeCmd = &cobra.Command{ runEslintAnalysis(workDirectory, args, autoFix, outputFile, outputFormat) case "trivy": runTrivyAnalysis(workDirectory, args, outputFile, outputFormat) + case "pmd": + runPmdAnalysis(workDirectory, args, outputFile, outputFormat) case "pylint": runPylintAnalysis(workDirectory, args, outputFile, outputFormat) case "": diff --git a/plugins/tool-utils.go b/plugins/tool-utils.go index 10723319..b2731d62 100644 --- a/plugins/tool-utils.go +++ b/plugins/tool-utils.go @@ -174,7 +174,23 @@ func ProcessTools(configs []ToolConfig, toolDir string) (map[string]*ToolInfo, e // Process binary paths for _, binary := range pluginConfig.Binaries { - binaryPath := path.Join(installDir, binary.Path) + // Process template variables in binary path + tmpl, err := template.New("binary_path").Parse(binary.Path) + if err != nil { + return nil, fmt.Errorf("error parsing binary path template for %s: %w", config.Name, err) + } + + var buf bytes.Buffer + err = tmpl.Execute(&buf, struct { + Version string + }{ + Version: config.Version, + }) + if err != nil { + return nil, fmt.Errorf("error executing binary path template for %s: %w", config.Name, err) + } + + binaryPath := filepath.Join(installDir, buf.String()) info.Binaries[binary.Name] = binaryPath } diff --git a/plugins/tools/pmd/plugin.yaml b/plugins/tools/pmd/plugin.yaml new file mode 100644 index 00000000..c9a2e572 --- /dev/null +++ b/plugins/tools/pmd/plugin.yaml @@ -0,0 +1,10 @@ +name: pmd +description: PMD - An extensible cross-language static code analyzer +download: + url_template: https://github.com/pmd/pmd/releases/download/pmd_releases%2F{{.Version}}/pmd-dist-{{.Version}}-bin.zip + file_name_template: pmd-dist-{{.Version}}-bin.zip + extension: + default: .zip +binaries: + - name: pmd + path: pmd-bin-{{.Version}}/bin/pmd \ No newline at end of file diff --git a/tools/pmdRunner.go b/tools/pmdRunner.go new file mode 100644 index 00000000..da1d523f --- /dev/null +++ b/tools/pmdRunner.go @@ -0,0 +1,42 @@ +package tools + +import ( + "os" + "os/exec" +) + +// RunPmd executes PMD static code analyzer with the specified options +func RunPmd(repositoryToAnalyseDirectory string, pmdBinary string, pathsToCheck []string, outputFile string, outputFormat string, rulesetFile string) error { + cmd := exec.Command(pmdBinary, "check") + + // Add ruleset file if provided + if rulesetFile != "" { + cmd.Args = append(cmd.Args, "--rulesets", rulesetFile) + } + + // Add format options + if outputFormat == "sarif" { + cmd.Args = append(cmd.Args, "--format", "sarif") + } + + if outputFile != "" { + cmd.Args = append(cmd.Args, "--report-file", outputFile) + } + + // Add directory to scan + cmd.Args = append(cmd.Args, "--dir", repositoryToAnalyseDirectory) + + cmd.Dir = repositoryToAnalyseDirectory + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + + err := cmd.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 4 { + // Exit status 4 means violations were found, which is not an error + return nil + } + return err + } + return nil +} diff --git a/tools/pmdRunner_test.go b/tools/pmdRunner_test.go new file mode 100644 index 00000000..6d8c850d --- /dev/null +++ b/tools/pmdRunner_test.go @@ -0,0 +1,64 @@ +package tools + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRunPmdToFile(t *testing.T) { + homeDirectory, err := os.UserHomeDir() + if err != nil { + log.Fatal(err.Error()) + } + currentDirectory, err := os.Getwd() + if err != nil { + log.Fatal(err.Error()) + } + + // Get absolute paths for test files + testDirectory := filepath.Join(currentDirectory, "testdata/repositories/pmd") + tempResultFile := filepath.Join(os.TempDir(), "pmd.sarif") + defer os.Remove(tempResultFile) + + // Use absolute paths for repository and ruleset + repositoryToAnalyze := testDirectory + rulesetFile := filepath.Join(testDirectory, "pmd-ruleset.xml") + + pmdBinary := filepath.Join(homeDirectory, ".cache/codacy/tools/pmd@7.12.0/pmd-bin-7.12.0/bin/pmd") + + err = RunPmd(repositoryToAnalyze, pmdBinary, nil, tempResultFile, "sarif", rulesetFile) + // PMD returns exit status 4 when violations are found, which is expected in our test + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 4 { + t.Fatalf("Failed to run PMD: %v", err) + } + } + + // Check if the output file was created + obtainedSarifBytes, err := os.ReadFile(tempResultFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + obtainedSarif := string(obtainedSarifBytes) + filePrefix := "file://" + currentDirectory + "/" + fmt.Println(filePrefix) + actualSarif := strings.ReplaceAll(obtainedSarif, filePrefix, "") + actualSarif = strings.TrimSpace(actualSarif) + + // Read the expected SARIF + expectedSarifFile := filepath.Join(testDirectory, "expected.sarif") + expectedSarifBytes, err := os.ReadFile(expectedSarifFile) + if err != nil { + log.Fatal(err) + } + expectedSarif := strings.TrimSpace(string(expectedSarifBytes)) + + assert.Equal(t, expectedSarif, actualSarif, "output did not match expected") +} diff --git a/tools/testdata/repositories/pmd/RulesBreaker.java b/tools/testdata/repositories/pmd/RulesBreaker.java new file mode 100644 index 00000000..fcb86a61 --- /dev/null +++ b/tools/testdata/repositories/pmd/RulesBreaker.java @@ -0,0 +1,20 @@ + +/** + * This class demonstrates various PMD rule violations. + * It is used for testing PMD's rule detection capabilities. + * + * @author Codacy Test Team + * @version 1.0 + */ + +package tools.testdata.repositories.pmd; + +/** + * A class that demonstrates PMD rule violations. + */ +public class RulesBreaker { + + // Breaking naming convention rules + private int x; + +} diff --git a/tools/testdata/repositories/pmd/expected.sarif b/tools/testdata/repositories/pmd/expected.sarif new file mode 100644 index 00000000..d6ff554b --- /dev/null +++ b/tools/testdata/repositories/pmd/expected.sarif @@ -0,0 +1,203 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "PMD", + "version": "7.12.0", + "informationUri": "https://docs.pmd-code.org/latest/", + "rules": [ + { + "id": "AtLeastOneConstructor", + "shortDescription": { + "text": "Each class should declare at least one constructor" + }, + "fullDescription": { + "text": "\n\nEach non-static class should declare at least one constructor.\nClasses with solely static members are ignored, refer to [UseUtilityClassRule](pmd_rules_java_design.html#useutilityclass) to detect those.\n\n " + }, + "helpUri": "https://docs.pmd-code.org/pmd-doc-7.12.0/pmd_rules_java_codestyle.html#atleastoneconstructor", + "help": { + "text": "\n\nEach non-static class should declare at least one constructor.\nClasses with solely static members are ignored, refer to [UseUtilityClassRule](pmd_rules_java_design.html#useutilityclass) to detect those.\n\n " + }, + "properties": { + "ruleset": "Code Style", + "priority": 3, + "tags": [ + "Code Style" + ] + } + }, + { + "id": "UnusedPrivateField", + "shortDescription": { + "text": "Avoid unused private fields such as 'x'." + }, + "fullDescription": { + "text": "\nDetects when a private field is declared and/or assigned a value, but not used.\n\nSince PMD 6.50.0 private fields are ignored, if the fields are annotated with any annotation or the\nenclosing class has any annotation. Annotations often enable a framework (such as dependency injection, mocking\nor e.g. Lombok) which use the fields by reflection or other means. This usage can't be detected by static code analysis.\nPreviously these frameworks where explicitly allowed by listing their annotations in the property\n\"ignoredAnnotations\", but that turned out to be prone of false positive for any not explicitly considered framework.\n " + }, + "helpUri": "https://docs.pmd-code.org/pmd-doc-7.12.0/pmd_rules_java_bestpractices.html#unusedprivatefield", + "help": { + "text": "\nDetects when a private field is declared and/or assigned a value, but not used.\n\nSince PMD 6.50.0 private fields are ignored, if the fields are annotated with any annotation or the\nenclosing class has any annotation. Annotations often enable a framework (such as dependency injection, mocking\nor e.g. Lombok) which use the fields by reflection or other means. This usage can't be detected by static code analysis.\nPreviously these frameworks where explicitly allowed by listing their annotations in the property\n\"ignoredAnnotations\", but that turned out to be prone of false positive for any not explicitly considered framework.\n " + }, + "properties": { + "ruleset": "Best Practices", + "priority": 3, + "tags": [ + "Best Practices" + ] + } + }, + { + "id": "ShortVariable", + "shortDescription": { + "text": "Avoid variables with short names like x" + }, + "fullDescription": { + "text": "\nFields, local variables, enum constant names or parameter names that are very short are not helpful to the reader.\n " + }, + "helpUri": "https://docs.pmd-code.org/pmd-doc-7.12.0/pmd_rules_java_codestyle.html#shortvariable", + "help": { + "text": "\nFields, local variables, enum constant names or parameter names that are very short are not helpful to the reader.\n " + }, + "properties": { + "ruleset": "Code Style", + "priority": 3, + "tags": [ + "Code Style" + ] + } + }, + { + "id": "CommentRequired", + "shortDescription": { + "text": "Field comments are required" + }, + "fullDescription": { + "text": "\nDenotes whether javadoc (formal) comments are required (or unwanted) for specific language elements.\n " + }, + "helpUri": "https://docs.pmd-code.org/pmd-doc-7.12.0/pmd_rules_java_documentation.html#commentrequired", + "help": { + "text": "\nDenotes whether javadoc (formal) comments are required (or unwanted) for specific language elements.\n " + }, + "properties": { + "ruleset": "Documentation", + "priority": 3, + "tags": [ + "Documentation" + ] + } + } + ] + } + }, + "results": [ + { + "ruleId": "AtLeastOneConstructor", + "ruleIndex": 0, + "message": { + "text": "Each class should declare at least one constructor" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "testdata/repositories/pmd/RulesBreaker.java" + }, + "region": { + "startLine": 15, + "startColumn": 8, + "endLine": 15, + "endColumn": 13 + } + } + } + ] + }, + { + "ruleId": "UnusedPrivateField", + "ruleIndex": 1, + "message": { + "text": "Avoid unused private fields such as 'x'." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "testdata/repositories/pmd/RulesBreaker.java" + }, + "region": { + "startLine": 18, + "startColumn": 17, + "endLine": 18, + "endColumn": 18 + } + } + } + ] + }, + { + "ruleId": "ShortVariable", + "ruleIndex": 2, + "message": { + "text": "Avoid variables with short names like x" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "testdata/repositories/pmd/RulesBreaker.java" + }, + "region": { + "startLine": 18, + "startColumn": 17, + "endLine": 18, + "endColumn": 18 + } + } + } + ] + }, + { + "ruleId": "CommentRequired", + "ruleIndex": 3, + "message": { + "text": "Field comments are required" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "testdata/repositories/pmd/RulesBreaker.java" + }, + "region": { + "startLine": 18, + "startColumn": 17, + "endLine": 18, + "endColumn": 18 + } + } + } + ] + } + ], + "invocations": [ + { + "executionSuccessful": false, + "toolConfigurationNotifications": [ + { + "associatedRule": { + "id": "LoosePackageCoupling" + }, + "message": { + "text": "No packages or classes specified" + } + } + ], + "toolExecutionNotifications": [] + } + ] + } + ] +} diff --git a/tools/testdata/repositories/pmd/pmd-ruleset.xml b/tools/testdata/repositories/pmd/pmd-ruleset.xml new file mode 100644 index 00000000..11377f9e --- /dev/null +++ b/tools/testdata/repositories/pmd/pmd-ruleset.xml @@ -0,0 +1,24 @@ + + + + + Basic PMD ruleset for Codacy analysis + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/utils/download.go b/utils/download.go index 59d15c99..a8c85ca7 100644 --- a/utils/download.go +++ b/utils/download.go @@ -3,22 +3,29 @@ package utils import ( "fmt" "io" + "log" "net/http" "os" "path/filepath" ) func DownloadFile(url string, destDir string) (string, error) { + log.Printf("Attempting to download from URL: %s", url) + // Get the file name from the URL fileName := filepath.Base(url) + log.Printf("Target filename: %s", fileName) // Create the destination file path destPath := filepath.Join(destDir, fileName) + log.Printf("Destination path: %s", destPath) _, errInfo := os.Stat(destPath) if errInfo != nil && os.IsExist(errInfo) { + log.Printf("File already exists at destination, skipping download") return destPath, nil } + // Create the destination file outFile, err := os.Create(destPath) if err != nil { @@ -27,7 +34,14 @@ func DownloadFile(url string, destDir string) (string, error) { defer outFile.Close() // Make the HTTP GET request - resp, err := http.Get(url) + log.Printf("Making HTTP GET request...") + client := &http.Client{} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", "Codacy-CLI") + resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to make GET request: %w", err) } @@ -35,14 +49,21 @@ func DownloadFile(url string, destDir string) (string, error) { // Check if the request was successful if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to download file: status code %d", resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to download file: status code %d, URL: %s, Response: %s", resp.StatusCode, url, string(body)) } // Copy the response body to the destination file - _, err = io.Copy(outFile, resp.Body) + log.Printf("Downloading file content...") + written, err := io.Copy(outFile, resp.Body) if err != nil { return "", fmt.Errorf("failed to copy file contents: %w", err) } + log.Printf("Downloaded %d bytes", written) + + if written == 0 { + return "", fmt.Errorf("downloaded file is empty (0 bytes)") + } return destPath, nil }