diff --git a/packages/code-analyzer-pmd-engine/package.json b/packages/code-analyzer-pmd-engine/package.json index fd3f737f..3688bc34 100644 --- a/packages/code-analyzer-pmd-engine/package.json +++ b/packages/code-analyzer-pmd-engine/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-pmd-engine", "description": "Plugin package that adds 'pmd' and 'cpd' as engines into Salesforce Code Analyzer", - "version": "0.36.0", + "version": "0.37.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", diff --git a/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdAstDumpInputData.java b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdAstDumpInputData.java new file mode 100644 index 00000000..8b55850e --- /dev/null +++ b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdAstDumpInputData.java @@ -0,0 +1,22 @@ +package com.salesforce.sfca.pmdwrapper; + +/** + * Input data structure for AST dump command + */ +public class PmdAstDumpInputData { + /** + * The language of the file to dump AST for (e.g., "apex", "xml", "visualforce") + */ + public String language; + + /** + * Single file to generate AST for + */ + public String fileToDump; + + /** + * Character encoding for reading the file + * Defaults to "UTF-8" if not specified + */ + public String encoding = "UTF-8"; +} diff --git a/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdAstDumpResults.java b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdAstDumpResults.java new file mode 100644 index 00000000..89fb62ad --- /dev/null +++ b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdAstDumpResults.java @@ -0,0 +1,26 @@ +package com.salesforce.sfca.pmdwrapper; + +import com.salesforce.sfca.shared.ProcessingError; + +/** + * Results structure for AST dump command. + * Contains either the AST (if successful) or an error (if failed), but never both. + */ +public class PmdAstDumpResults { + /** + * Full path to the file that was processed + */ + public String file; + + /** + * The AST representation in XML format + * This is populated if the AST generation was successful, null otherwise + */ + public String ast; + + /** + * Error details if AST generation failed + * This is populated if the AST generation failed, null otherwise + */ + public ProcessingError error; +} diff --git a/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdAstDumper.java b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdAstDumper.java new file mode 100644 index 00000000..d08fcf94 --- /dev/null +++ b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdAstDumper.java @@ -0,0 +1,122 @@ +package com.salesforce.sfca.pmdwrapper; + +import com.salesforce.sfca.shared.ProcessingError; +import net.sourceforge.pmd.lang.Language; +import net.sourceforge.pmd.lang.LanguageRegistry; +import net.sourceforge.pmd.util.treeexport.TreeExportConfiguration; +import net.sourceforge.pmd.util.treeexport.TreeExporter; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Core class that performs AST dumping using PMD's TreeExporter API + */ +public class PmdAstDumper { + + /** + * Dumps the AST for a single file in XML format + * + * @param inputData Input data containing language, file path, and encoding + * @return Results containing either the AST (if successful) or error details (if failed) + */ + public PmdAstDumpResults dump(PmdAstDumpInputData inputData) { + validateInputData(inputData); + + PmdAstDumpResults results = new PmdAstDumpResults(); + results.file = inputData.fileToDump; + + try { + System.out.println("Generating AST for file '" + inputData.fileToDump + "' with language '" + inputData.language + "'"); + + // Verify file exists and is valid (lightweight validation without reading content) + Path filePath = Paths.get(inputData.fileToDump); + validateFilePath(filePath, inputData.encoding); + + // Get language + Language language = LanguageRegistry.PMD.getLanguageById(inputData.language); + if (language == null) { + throw new RuntimeException("Language not supported: " + inputData.language); + } + + // Create TreeExportConfiguration + TreeExportConfiguration config = new TreeExportConfiguration(); + config.setLanguage(language); + config.setFormat("xml"); // Always XML format for v1 + config.setFile(filePath); + + // Capture output to string + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + PrintStream capturedPrintStream = new PrintStream(outputStream, true, StandardCharsets.UTF_8); + PrintStream originalOut = System.out; + + try { + // Redirect System.out to capture XML output + System.setOut(capturedPrintStream); + + // Create and export AST (TreeExporter writes to System.out) + TreeExporter exporter = new TreeExporter(config); + exporter.export(); + + // Get the XML output + results.ast = outputStream.toString(StandardCharsets.UTF_8); + + } finally { + // Restore original System.out + System.setOut(originalOut); + capturedPrintStream.close(); + } + + System.out.println("Successfully generated AST for file '" + inputData.fileToDump + "'"); + + } catch (Exception e) { + // Store processing error + System.err.println("Error generating AST for file '" + inputData.fileToDump + "': " + e.getMessage()); + ProcessingError error = new ProcessingError(); + error.file = inputData.fileToDump; + error.message = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + error.detail = e.toString(); + results.error = error; + } + + return results; + } + + /** + * Validates the input data + */ + private void validateInputData(PmdAstDumpInputData inputData) { + if (inputData.language == null || inputData.language.trim().isEmpty()) { + throw new RuntimeException("The 'language' field is required"); + } + if (inputData.fileToDump == null || inputData.fileToDump.trim().isEmpty()) { + throw new RuntimeException("The 'fileToDump' field is required"); + } + if (inputData.encoding == null || inputData.encoding.trim().isEmpty()) { + inputData.encoding = "UTF-8"; + } + } + + /** + * Validates file path and encoding without reading file content. + * This avoids loading large files into memory unnecessarily. + * PMD's TreeExporter will handle reading the file content. + */ + private void validateFilePath(Path filePath, String encoding) throws IOException { + if (!Files.exists(filePath)) { + throw new IOException("File not found: " + filePath); + } + if (!Files.isRegularFile(filePath)) { + throw new IOException("Not a regular file: " + filePath); + } + + // Validate encoding by attempting to get the Charset (throws if invalid) + Charset.forName(encoding); + } +} diff --git a/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdWrapper.java b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdWrapper.java index c00ca280..33257701 100644 --- a/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdWrapper.java +++ b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/pmdwrapper/PmdWrapper.java @@ -67,6 +67,34 @@ * } * ] * } + * AST-DUMP: + * - Generates Abstract Syntax Tree representation of a source file in XML format + * - Invocation: java -cp {classPath} com.salesforce.sfca.pmdwrapper.PmdWrapper ast-dump {argsInputFile} {resultsOutputFile} + * - {classPath} is the list of entries to add to the class path + * - {argsInputFile} is a JSON file containing the input arguments for the ast-dump command. + * Example: + * { + * "language": "apex", + * "fileToDump": "/full/path/to/MyClass.cls", + * "encoding": "UTF-8" + * } + * - {resultsOutputFile} is a file to write the JSON formatted AST dump results to + * Example (success): + * { + * "file": "/full/path/to/MyClass.cls", + * "ast": "\n...", + * "error": null + * } + * Example (error): + * { + * "file": "/full/path/to/MyClass.cls", + * "ast": null, + * "error": { + * "file": "/full/path/to/MyClass.cls", + * "message": "ParseException: Unexpected token", + * "detail": "..." + * } + * } */ public class PmdWrapper { @@ -82,8 +110,10 @@ public static void main(String[] args) { invokeDescribeCommand(Arrays.copyOfRange(args, 1, args.length)); } else if(args[0].equalsIgnoreCase("run")) { invokeRunCommand(Arrays.copyOfRange(args, 1, args.length)); + } else if(args[0].equalsIgnoreCase("ast-dump")) { + invokeAstDumpCommand(Arrays.copyOfRange(args, 1, args.length)); } else { - throw new RuntimeException("Bad first argument to PmdWrapper. Expected \"describe\" or \"run\". Received: \"" + args[0] + "\""); + throw new RuntimeException("Bad first argument to PmdWrapper. Expected \"describe\", \"run\", or \"ast-dump\". Received: \"" + args[0] + "\""); } long endTime = System.currentTimeMillis(); @@ -135,7 +165,7 @@ private static void invokeRunCommand(String[] args) { try (FileReader reader = new FileReader(argsInputFile)) { inputData = gson.fromJson(reader, PmdRunInputData.class); } catch (Exception e) { - throw new RuntimeException("Could not read contents from \"" + argsInputFile + "\"", e); + throw new RuntimeException("Could not read contents from \"" + argsInputFile + "\": " + e.getMessage(), e); } PmdRunner pmdRunner = new PmdRunner(); @@ -152,4 +182,38 @@ private static void invokeRunCommand(String[] args) { throw new RuntimeException(e); } } + + private static void invokeAstDumpCommand(String[] args) { + if (args.length != 2) { + throw new RuntimeException("Invalid number of arguments following the \"ast-dump\" command. Expected 2 but received: " + args.length); + } + String argsInputFile = args[0]; + String resultsOutputFile = args[1]; + + Gson gson = new Gson(); + + // Read input data + PmdAstDumpInputData inputData; + try (FileReader reader = new FileReader(argsInputFile)) { + inputData = gson.fromJson(reader, PmdAstDumpInputData.class); + } catch (Exception e) { + throw new RuntimeException("Could not read contents from \"" + argsInputFile + "\": " + e.getMessage(), e); + } + + // Execute AST dump + PmdAstDumper astDumper = new PmdAstDumper(); + PmdAstDumpResults results; + try { + results = astDumper.dump(inputData); + } catch (Exception e) { + throw new RuntimeException("Error while attempting to invoke PmdAstDumper.dump: " + e.getMessage(), e); + } + + // Write results + try (FileWriter fileWriter = new FileWriter(resultsOutputFile)) { + gson.toJson(results, fileWriter); + } catch (IOException e) { + throw new RuntimeException(e); + } + } } \ No newline at end of file diff --git a/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/test/java/com/salesforce/sfca/pmdwrapper/PmdAstDumpTest.java b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/test/java/com/salesforce/sfca/pmdwrapper/PmdAstDumpTest.java new file mode 100644 index 00000000..5bdbd51d --- /dev/null +++ b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/test/java/com/salesforce/sfca/pmdwrapper/PmdAstDumpTest.java @@ -0,0 +1,518 @@ +package com.salesforce.sfca.pmdwrapper; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.gson.Gson; +import com.salesforce.sfca.testtools.StdOutCaptor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.FileNotFoundException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Tests for PMD AST Dump functionality + */ +class PmdAstDumpTest { + + @Test + void whenCallingMainWithAstDumpAndTooFewArgs_thenError() { + String[] args = {"ast-dump", "notEnough"}; + Exception thrown = assertThrows(Exception.class, () -> callPmdWrapper(args)); + assertThat(thrown.getMessage(), is("Invalid number of arguments following the \"ast-dump\" command. Expected 2 but received: 1")); + } + + @Test + void whenCallingMainWithAstDumpAndTooManyArgs_thenError() { + String[] args = {"ast-dump", "too", "many", "args"}; + Exception thrown = assertThrows(Exception.class, () -> callPmdWrapper(args)); + assertThat(thrown.getMessage(), is("Invalid number of arguments following the \"ast-dump\" command. Expected 2 but received: 3")); + } + + @Test + void whenCallingMainWithAstDumpAndInputFileThatDoesNotExist_thenError() { + String[] args = {"ast-dump", "/does/not/exist.json", "/does/not/matter"}; + RuntimeException thrown = assertThrows(RuntimeException.class, () -> callPmdWrapper(args)); + assertThat(thrown.getMessage(), containsString("Could not read contents from \"/does/not/exist.json\"")); + assertThat(thrown.getCause(), instanceOf(FileNotFoundException.class)); + } + + @Test + void whenCallingAstDumpWithValidApexCode_thenGeneratesNonEmptyXmlAst(@TempDir Path tempDir) throws Exception { + // Create a simple Apex class + String apexCode = "public class TestClass {\n" + + " public String name;\n" + + " \n" + + " public void sayHello() {\n" + + " System.debug('Hello World');\n" + + " }\n" + + "}"; + String apexFile = createTempFile(tempDir, "TestClass.cls", apexCode); + + // Create input JSON for ast-dump command + String inputFileContents = "{\n" + + " \"language\": \"apex\",\n" + + " \"fileToDump\": \"" + makePathJsonSafe(apexFile) + "\",\n" + + " \"encoding\": \"UTF-8\"\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + String stdOut = callPmdWrapper(args); + + // Read and parse the results + String resultsJsonString = new String(Files.readAllBytes(Paths.get(resultsOutputFile))); + Gson gson = new Gson(); + PmdAstDumpResults results = gson.fromJson(resultsJsonString, PmdAstDumpResults.class); + + // Assert the AST was generated successfully + assertThat(results.file, is(apexFile)); + assertThat(results.ast, is(notNullValue())); + assertThat(results.ast.length(), greaterThan(100)); // AST should be substantial + assertThat(results.error, is(nullValue())); + + // Assert the AST contains expected XML structure + assertThat(results.ast, containsString("\n" + + "

Hello World

\n" + + " \n" + + ""; + String vfFile = createTempFile(tempDir, "TestPage.page", vfCode); + + // Create input JSON for ast-dump command + String inputFileContents = "{\n" + + " \"language\": \"visualforce\",\n" + + " \"fileToDump\": \"" + makePathJsonSafe(vfFile) + "\",\n" + + " \"encoding\": \"UTF-8\"\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + callPmdWrapper(args); + + // Read and parse the results + String resultsJsonString = new String(Files.readAllBytes(Paths.get(resultsOutputFile))); + Gson gson = new Gson(); + PmdAstDumpResults results = gson.fromJson(resultsJsonString, PmdAstDumpResults.class); + + // Assert the AST was generated successfully + assertThat(results.file, is(vfFile)); + assertThat(results.ast, is(notNullValue())); + assertThat(results.ast.length(), greaterThan(50)); + assertThat(results.error, is(nullValue())); + + // Assert the AST contains expected XML structure + assertThat(results.ast, containsString(" callPmdWrapper(args)); + assertThat(thrown.getMessage(), containsString("'language' field is required")); + } + + @Test + void whenCallingAstDumpWithEmptyLanguage_thenThrowsException(@TempDir Path tempDir) throws Exception { + // Create a test file + String testFile = createTempFile(tempDir, "test.txt", "some content"); + + // Create input JSON with empty language + String inputFileContents = "{\n" + + " \"language\": \" \",\n" + + " \"fileToDump\": \"" + makePathJsonSafe(testFile) + "\",\n" + + " \"encoding\": \"UTF-8\"\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command - should throw exception + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + RuntimeException thrown = assertThrows(RuntimeException.class, () -> callPmdWrapper(args)); + assertThat(thrown.getMessage(), containsString("'language' field is required")); + } + + @Test + void whenCallingAstDumpWithNullFileToDump_thenThrowsException(@TempDir Path tempDir) throws Exception { + // Create input JSON with null fileToDump + String inputFileContents = "{\n" + + " \"language\": \"apex\",\n" + + " \"fileToDump\": null,\n" + + " \"encoding\": \"UTF-8\"\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command - should throw exception + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + RuntimeException thrown = assertThrows(RuntimeException.class, () -> callPmdWrapper(args)); + assertThat(thrown.getMessage(), containsString("'fileToDump' field is required")); + } + + @Test + void whenCallingAstDumpWithEmptyFileToDump_thenThrowsException(@TempDir Path tempDir) throws Exception { + // Create input JSON with empty fileToDump + String inputFileContents = "{\n" + + " \"language\": \"apex\",\n" + + " \"fileToDump\": \" \",\n" + + " \"encoding\": \"UTF-8\"\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command - should throw exception + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + RuntimeException thrown = assertThrows(RuntimeException.class, () -> callPmdWrapper(args)); + assertThat(thrown.getMessage(), containsString("'fileToDump' field is required")); + } + + @Test + void whenCallingAstDumpWithNullEncoding_thenDefaultsToUtf8(@TempDir Path tempDir) throws Exception { + // Create a simple Apex class + String apexCode = "public class TestClass { }"; + String apexFile = createTempFile(tempDir, "TestClass.cls", apexCode); + + // Create input JSON with null encoding + String inputFileContents = "{\n" + + " \"language\": \"apex\",\n" + + " \"fileToDump\": \"" + makePathJsonSafe(apexFile) + "\",\n" + + " \"encoding\": null\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + callPmdWrapper(args); + + // Read and parse the results + String resultsJsonString = new String(Files.readAllBytes(Paths.get(resultsOutputFile))); + Gson gson = new Gson(); + PmdAstDumpResults results = gson.fromJson(resultsJsonString, PmdAstDumpResults.class); + + // Assert the AST was generated successfully (encoding defaulted to UTF-8) + assertThat(results.file, is(apexFile)); + assertThat(results.ast, is(notNullValue())); + assertThat(results.error, is(nullValue())); + } + + @Test + void whenCallingAstDumpWithEmptyEncoding_thenDefaultsToUtf8(@TempDir Path tempDir) throws Exception { + // Create a simple Apex class + String apexCode = "public class TestClass { }"; + String apexFile = createTempFile(tempDir, "TestClass.cls", apexCode); + + // Create input JSON with empty encoding + String inputFileContents = "{\n" + + " \"language\": \"apex\",\n" + + " \"fileToDump\": \"" + makePathJsonSafe(apexFile) + "\",\n" + + " \"encoding\": \" \"\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + callPmdWrapper(args); + + // Read and parse the results + String resultsJsonString = new String(Files.readAllBytes(Paths.get(resultsOutputFile))); + Gson gson = new Gson(); + PmdAstDumpResults results = gson.fromJson(resultsJsonString, PmdAstDumpResults.class); + + // Assert the AST was generated successfully (encoding defaulted to UTF-8) + assertThat(results.file, is(apexFile)); + assertThat(results.ast, is(notNullValue())); + assertThat(results.error, is(nullValue())); + } + + @Test + void whenCallingAstDumpWithInvalidEncoding_thenReturnsError(@TempDir Path tempDir) throws Exception { + // Create a simple Apex class + String apexCode = "public class TestClass { }"; + String apexFile = createTempFile(tempDir, "TestClass.cls", apexCode); + + // Create input JSON with invalid encoding + String inputFileContents = "{\n" + + " \"language\": \"apex\",\n" + + " \"fileToDump\": \"" + makePathJsonSafe(apexFile) + "\",\n" + + " \"encoding\": \"INVALID-ENCODING-NAME\"\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + callPmdWrapper(args); + + // Read and parse the results + String resultsJsonString = new String(Files.readAllBytes(Paths.get(resultsOutputFile))); + Gson gson = new Gson(); + PmdAstDumpResults results = gson.fromJson(resultsJsonString, PmdAstDumpResults.class); + + // Assert error is returned + assertThat(results.file, is(apexFile)); + assertThat(results.ast, is(nullValue())); + assertThat(results.error, is(notNullValue())); + assertThat(results.error.message, anyOf( + containsString("INVALID-ENCODING-NAME"), + containsString("Charset"), + containsString("encoding"))); + } + + @Test + void whenCallingAstDumpWithDirectory_thenReturnsError(@TempDir Path tempDir) throws Exception { + // Use the temp directory itself as the file to dump + String dirPath = tempDir.toAbsolutePath().toString(); + + // Create input JSON pointing to a directory + String inputFileContents = "{\n" + + " \"language\": \"apex\",\n" + + " \"fileToDump\": \"" + makePathJsonSafe(dirPath) + "\",\n" + + " \"encoding\": \"UTF-8\"\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + callPmdWrapper(args); + + // Read and parse the results + String resultsJsonString = new String(Files.readAllBytes(Paths.get(resultsOutputFile))); + Gson gson = new Gson(); + PmdAstDumpResults results = gson.fromJson(resultsJsonString, PmdAstDumpResults.class); + + // Assert error is returned + assertThat(results.file, is(dirPath)); + assertThat(results.ast, is(nullValue())); + assertThat(results.error, is(notNullValue())); + assertThat(results.error.message, containsString("Not a regular file")); + } + + @Test + void whenCallingAstDumpWithEmptyFile_thenGeneratesAst(@TempDir Path tempDir) throws Exception { + // Create an empty Apex file + String apexFile = createTempFile(tempDir, "Empty.cls", ""); + + // Create input JSON + String inputFileContents = "{\n" + + " \"language\": \"apex\",\n" + + " \"fileToDump\": \"" + makePathJsonSafe(apexFile) + "\",\n" + + " \"encoding\": \"UTF-8\"\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + callPmdWrapper(args); + + // Read and parse the results + String resultsJsonString = new String(Files.readAllBytes(Paths.get(resultsOutputFile))); + Gson gson = new Gson(); + PmdAstDumpResults results = gson.fromJson(resultsJsonString, PmdAstDumpResults.class); + + // Empty files may generate an AST or return an error depending on PMD behavior + // Either outcome is acceptable - we're verifying no exception is thrown + assertThat(results.file, is(apexFile)); + // Don't assert on ast or error - PMD behavior may vary for empty files + } + + @Test + void whenCallingAstDumpWithIso88591Encoding_thenGeneratesAst(@TempDir Path tempDir) throws Exception { + // Create an Apex file with special characters in ISO-8859-1 encoding + String apexCode = "public class TestClass {\n" + + " // Comment with special char: \u00E9\n" + // é in ISO-8859-1 + " public String name;\n" + + "}"; + Path apexPath = tempDir.resolve("TestClass.cls"); + Files.write(apexPath, apexCode.getBytes("ISO-8859-1")); + String apexFile = apexPath.toAbsolutePath().toString(); + + // Create input JSON with ISO-8859-1 encoding + String inputFileContents = "{\n" + + " \"language\": \"apex\",\n" + + " \"fileToDump\": \"" + makePathJsonSafe(apexFile) + "\",\n" + + " \"encoding\": \"ISO-8859-1\"\n" + + "}"; + String inputFile = createTempFile(tempDir, "astDumpInput.json", inputFileContents); + + String resultsOutputFile = tempDir.resolve("astDumpOutput.json").toAbsolutePath().toString(); + + // Execute ast-dump command + String[] args = {"ast-dump", inputFile, resultsOutputFile}; + callPmdWrapper(args); + + // Read and parse the results + String resultsJsonString = new String(Files.readAllBytes(Paths.get(resultsOutputFile))); + Gson gson = new Gson(); + PmdAstDumpResults results = gson.fromJson(resultsJsonString, PmdAstDumpResults.class); + + // Assert the AST was generated successfully with correct encoding + assertThat(results.file, is(apexFile)); + assertThat(results.ast, is(notNullValue())); + assertThat(results.error, is(nullValue())); + } + + // ===================== HELPER METHODS ===================== + + private static String createTempFile(Path tempDir, String fileName, String fileContents) throws Exception { + Path inputFilePath = tempDir.resolve(fileName); + Files.write(inputFilePath, fileContents.getBytes()); + return inputFilePath.toAbsolutePath().toString(); + } + + private static String callPmdWrapper(String[] args) { + try (StdOutCaptor stdoutCaptor = new StdOutCaptor()) { + PmdWrapper.main(args); + return stdoutCaptor.getCapturedOutput(); + } + } + + private static String makePathJsonSafe(String file) { + return file.replace("\\", "\\\\") // Escape backslashes + .replace("\"", "\\\""); // Escape double quotes + } +} diff --git a/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/test/java/com/salesforce/sfca/pmdwrapper/PmdWrapperTest.java b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/test/java/com/salesforce/sfca/pmdwrapper/PmdWrapperTest.java index 49120e8e..98faee09 100644 --- a/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/test/java/com/salesforce/sfca/pmdwrapper/PmdWrapperTest.java +++ b/packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/test/java/com/salesforce/sfca/pmdwrapper/PmdWrapperTest.java @@ -35,7 +35,7 @@ void whenCallingMainWithNoCommand_thenError() { void whenCallingMainWithUnsupportedCommand_thenError() { String[] args = {"oops", "abc"}; Exception thrown = assertThrows(Exception.class, () -> callPmdWrapper(args)); - assertThat(thrown.getMessage(), is("Bad first argument to PmdWrapper. Expected \"describe\" or \"run\". Received: \"oops\"")); + assertThat(thrown.getMessage(), is("Bad first argument to PmdWrapper. Expected \"describe\", \"run\", or \"ast-dump\". Received: \"oops\"")); } @Test @@ -528,7 +528,6 @@ void whenRunningWithDeprecatedExcessiveClassLengthRule_thenExecutesSuccessfully( assertThat(element.isJsonObject(), is(true)); } - private static String createSampleRulesetFile(Path tempDir) throws Exception { String ruleSetContents = "\n" + " { + const encoding = options?.encoding || 'UTF-8'; + const workingFolder = options?.workingFolder || await fs.mkdtemp(path.join(os.tmpdir(), 'pmd-ast-dump-')); + + this.emitLogEvent(LogLevel.Fine, `Generating AST for file: ${file} (language: ${language})`); + + try { + const results = await this.pmdWrapperInvoker.invokeAstDumpCommand( + language, + file, + workingFolder, + encoding, + () => {} // No progress reporting at engine level + ); + + if (results.error) { + this.emitLogEvent(LogLevel.Error, `Failed to generate AST for ${file}: ${results.error.message}`); + } else { + this.emitLogEvent(LogLevel.Fine, `Successfully generated AST for ${file}`); + } + + return results; + } finally { + // Clean up temporary working folder if we created it + if (!options?.workingFolder) { + await fs.rm(workingFolder, {recursive: true, force: true}).catch(() => { + // Ignore cleanup errors + }); + } + } + } + private async getPmdRuleInfoList(workspaceLiaison: WorkspaceLiaison, workingFolder: string, emitProgress: (percComplete: number) => void): Promise { diff --git a/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts b/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts index a80b2049..ed26a98f 100644 --- a/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts +++ b/packages/code-analyzer-pmd-engine/src/pmd-wrapper.ts @@ -46,6 +46,23 @@ export type PmdProcessingError = { detail: string } +export type PmdAstDumpInputData = { + language: string + fileToDump: string + encoding?: string +} + +export type PmdAstDumpResults = { + file: string + ast: string | null + error: PmdProcessingError | null +} + +export type GenerateAstOptions = { + encoding?: string + workingFolder?: string +} + const STDOUT_PROGRESS_MARKER = '[Progress]'; const STDOUT_ERROR_MARKER = '[Error] '; const STDOUT_WARNING_MARKER = '[Warning] '; @@ -148,6 +165,62 @@ export class PmdWrapperInvoker { throw new Error(getMessageFromCatalog(SHARED_MESSAGE_CATALOG, 'ErrorParsingOutputFile', resultsOutputFile, errMsg), {cause: err}); } } + + async invokeAstDumpCommand( + language: string, + fileToDump: string, + workingFolder: string, + encoding: string = 'UTF-8', + emitProgress: (percComplete: number) => void + ): Promise { + + emitProgress(5); + + // Prepare input data + const inputData: PmdAstDumpInputData = { + language: language, + fileToDump: fileToDump, + encoding: encoding + }; + + const inputFile: string = path.join(workingFolder, 'astDumpInput.json'); + await fs.promises.writeFile(inputFile, JSON.stringify(inputData), 'utf-8'); + emitProgress(10); + + const resultsOutputFile: string = path.join(workingFolder, 'astDumpResults.json'); + const javaCmdArgs: string[] = [PMD_WRAPPER_JAVA_CLASS, 'ast-dump', inputFile, resultsOutputFile]; + const javaClassPaths: string[] = [ + path.join(PMD_WRAPPER_LIB_FOLDER, '*'), + ...this.userProvidedJavaClasspathEntries.map(toJavaClasspathEntry) + ]; + + this.emitLogEvent(LogLevel.Fine, `Calling AST dump for file: ${fileToDump}`); + + await this.javaCommandExecutor.exec(javaCmdArgs, javaClassPaths, (stdOutMsg: string) => { + if (stdOutMsg.startsWith(STDOUT_ERROR_MARKER)) { + const errorMessage: string = stdOutMsg.slice(STDOUT_ERROR_MARKER.length).replaceAll('{NEWLINE}','\n'); + throw new Error(errorMessage); + } else if (stdOutMsg.startsWith(STDOUT_WARNING_MARKER)) { + const warningMessage: string = stdOutMsg.slice(STDOUT_WARNING_MARKER.length).replaceAll('{NEWLINE}','\n'); + this.emitLogEvent(LogLevel.Warn, `[JAVA StdOut]: ${warningMessage}`); + } else { + this.emitLogEvent(LogLevel.Fine, `[JAVA StdOut]: ${stdOutMsg}`); + } + }); + + emitProgress(95); + + // Read and parse results + try { + const resultsFileContents: string = await fs.promises.readFile(resultsOutputFile, 'utf-8'); + const results: PmdAstDumpResults = JSON.parse(resultsFileContents); + emitProgress(100); + return results; + } catch (err) /* istanbul ignore next */ { + const errMsg: string = err instanceof Error ? err.message : String(err); + throw new Error(getMessageFromCatalog(SHARED_MESSAGE_CATALOG, 'ErrorParsingOutputFile', resultsOutputFile, errMsg), {cause: err}); + } + } } function createRuleSetFileContentsFor(pmdRuleInfoList: PmdRuleInfo[]): string { diff --git a/packages/code-analyzer-pmd-engine/test/pmd-ast-dump.test.ts b/packages/code-analyzer-pmd-engine/test/pmd-ast-dump.test.ts new file mode 100644 index 00000000..278c63a9 --- /dev/null +++ b/packages/code-analyzer-pmd-engine/test/pmd-ast-dump.test.ts @@ -0,0 +1,197 @@ +import {changeWorkingDirectoryToPackageRoot} from "./test-helpers"; +import {LogLevel} from "@salesforce/code-analyzer-engine-api"; +import {JavaCommandExecutor} from "@salesforce/code-analyzer-engine-api/utils"; +import {PmdWrapperInvoker, PmdAstDumpResults} from "../src/pmd-wrapper"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +changeWorkingDirectoryToPackageRoot(); + +const TEST_DATA_FOLDER: string = path.join(__dirname, 'test-data'); + +describe('Tests for invokeAstDumpCommand method of PmdWrapperInvoker', () => { + let javaCommandExecutor: JavaCommandExecutor; + let pmdWrapperInvoker: PmdWrapperInvoker; + let workingFolder: string; + let logEvents: Array<{level: LogLevel, message: string}>; + + beforeEach(() => { + javaCommandExecutor = new JavaCommandExecutor(); + logEvents = []; + pmdWrapperInvoker = new PmdWrapperInvoker( + javaCommandExecutor, + [], + (level: LogLevel, message: string) => { + logEvents.push({level, message}); + } + ); + workingFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'pmd-ast-dump-test-')); + }); + + afterEach(() => { + // Clean up working folder + if (fs.existsSync(workingFolder)) { + fs.rmSync(workingFolder, {recursive: true, force: true}); + } + }); + + it('When calling invokeAstDumpCommand with valid Apex file, then AST is generated successfully', async () => { + const apexFile = path.join(TEST_DATA_FOLDER, 'samplePmdWorkspace', 'sampleViolations', 'AvoidDebugStatements.cls'); + const progressEvents: number[] = []; + + const results: PmdAstDumpResults = await pmdWrapperInvoker.invokeAstDumpCommand( + 'apex', + apexFile, + workingFolder, + 'UTF-8', + (progress: number) => progressEvents.push(progress) + ); + + // Assert results + expect(results.file).toBe(apexFile); + expect(results.ast).toBeDefined(); + expect(results.ast).not.toBeNull(); + expect(results.ast!).toContain(' e.level === LogLevel.Fine); + expect(fineLogEvents.length).toBeGreaterThan(0); + expect(fineLogEvents.some(e => e.message.includes('Calling AST dump'))).toBe(true); + }); + + it('When calling invokeAstDumpCommand with valid Visualforce file, then AST is generated successfully', async () => { + const vfFile = path.join(TEST_DATA_FOLDER, 'samplePmdWorkspace', 'sampleViolations', 'VfUnescapeEl.page'); + const progressEvents: number[] = []; + + const results: PmdAstDumpResults = await pmdWrapperInvoker.invokeAstDumpCommand( + 'visualforce', + vfFile, + workingFolder, + 'UTF-8', + (progress: number) => progressEvents.push(progress) + ); + + // Assert results + expect(results.file).toBe(vfFile); + expect(results.ast).toBeDefined(); + expect(results.ast).not.toBeNull(); + expect(results.ast!).toContain(' { + const nonExistentFile = path.join(workingFolder, 'DoesNotExist.cls'); + + const results: PmdAstDumpResults = await pmdWrapperInvoker.invokeAstDumpCommand( + 'apex', + nonExistentFile, + workingFolder, + 'UTF-8', + () => {} + ); + + // Assert error is returned + expect(results.file).toBe(nonExistentFile); + expect(results.ast).toBeFalsy(); // null or undefined + expect(results.error).toBeDefined(); + expect(results.error!.file).toBe(nonExistentFile); + expect(results.error!.message).toContain('File not found'); + }); + + it('When calling invokeAstDumpCommand with invalid language, then error is returned', async () => { + const apexFile = path.join(TEST_DATA_FOLDER, 'samplePmdWorkspace', 'sampleViolations', 'AvoidDebugStatements.cls'); + + const results: PmdAstDumpResults = await pmdWrapperInvoker.invokeAstDumpCommand( + 'invalid_language', + apexFile, + workingFolder, + 'UTF-8', + () => {} + ); + + // Assert error is returned + expect(results.file).toBe(apexFile); + expect(results.ast).toBeFalsy(); // null or undefined + expect(results.error).toBeDefined(); + expect(results.error!.message).toContain('Language not supported'); + }); + + it('When calling invokeAstDumpCommand with invalid Apex syntax, then error is returned', async () => { + // Create a file with invalid Apex syntax + const invalidApexFile = path.join(workingFolder, 'Invalid.cls'); + const invalidApexCode = 'public class Invalid {\n #### SYNTAX ERROR ####\n}'; + fs.writeFileSync(invalidApexFile, invalidApexCode, 'utf-8'); + + const results: PmdAstDumpResults = await pmdWrapperInvoker.invokeAstDumpCommand( + 'apex', + invalidApexFile, + workingFolder, + 'UTF-8', + () => {} + ); + + // Assert error is returned + expect(results.file).toBe(invalidApexFile); + expect(results.ast).toBeFalsy(); // null or undefined + expect(results.error).toBeDefined(); + expect(results.error!.file).toBe(invalidApexFile); + // Error message should indicate parsing issue + expect(results.error!.message.length).toBeGreaterThan(0); + }); + + it('When calling invokeAstDumpCommand with valid encoding parameter, then AST is generated', async () => { + const apexFile = path.join(TEST_DATA_FOLDER, 'samplePmdWorkspace', 'sampleViolations', 'AvoidDebugStatements.cls'); + + const results: PmdAstDumpResults = await pmdWrapperInvoker.invokeAstDumpCommand( + 'apex', + apexFile, + workingFolder, + 'UTF-8', // Use UTF-8 since the file is actually UTF-8 + () => {} + ); + + // Should succeed + expect(results.file).toBe(apexFile); + expect(results.ast).toBeDefined(); + expect(results.ast).not.toBeNull(); + expect(results.error).toBeUndefined(); + }); + + it('When calling invokeAstDumpCommand, then input and output files are created in working folder', async () => { + const apexFile = path.join(TEST_DATA_FOLDER, 'samplePmdWorkspace', 'sampleViolations', 'AvoidDebugStatements.cls'); + + await pmdWrapperInvoker.invokeAstDumpCommand( + 'apex', + apexFile, + workingFolder, + 'UTF-8', + () => {} + ); + + // Verify input file was created + const inputFile = path.join(workingFolder, 'astDumpInput.json'); + expect(fs.existsSync(inputFile)).toBe(true); + + const inputData = JSON.parse(fs.readFileSync(inputFile, 'utf-8')); + expect(inputData.language).toBe('apex'); + expect(inputData.fileToDump).toBe(apexFile); + expect(inputData.encoding).toBe('UTF-8'); + + // Verify output file was created + const outputFile = path.join(workingFolder, 'astDumpResults.json'); + expect(fs.existsSync(outputFile)).toBe(true); + }); +}); diff --git a/packages/code-analyzer-pmd-engine/test/pmd-engine.test.ts b/packages/code-analyzer-pmd-engine/test/pmd-engine.test.ts index ad4e82fd..198de645 100644 --- a/packages/code-analyzer-pmd-engine/test/pmd-engine.test.ts +++ b/packages/code-analyzer-pmd-engine/test/pmd-engine.test.ts @@ -15,6 +15,7 @@ import { } from "@salesforce/code-analyzer-engine-api"; import {PmdEngine} from "../src/pmd-engine"; import fs from "node:fs"; +import * as fsPromises from "node:fs/promises"; import path from "node:path"; import {Language, PMD_VERSION} from "../src/constants"; import {DEFAULT_PMD_ENGINE_CONFIG, PMD_AVAILABLE_LANGUAGES, PmdEngineConfig} from "../src/config"; @@ -649,6 +650,107 @@ describe('Tests for the getEngineVersion method of PmdEngine', () => { }); }); +describe('Tests for the generateAst method of PmdEngine', () => { + it('When calling generateAst with valid Apex file, then AST is returned', async () => { + const engine: PmdEngine = new PmdEngine(DEFAULT_PMD_ENGINE_CONFIG); + const apexFile = path.join(TEST_DATA_FOLDER, 'samplePmdWorkspace', 'sampleViolations', 'AvoidDebugStatements.cls'); + + const logEvents: LogEvent[] = []; + engine.onEvent(EventType.LogEvent, (e: LogEvent) => logEvents.push(e)); + + const results = await engine.generateAst('apex', apexFile); + + expect(results.file).toBe(apexFile); + expect(results.ast).toBeDefined(); + expect(results.ast).not.toBeNull(); + expect(results.ast!).toContain(' e.logLevel === LogLevel.Fine); + expect(fineLogEvents.some(e => e.message.includes('Generating AST'))).toBe(true); + expect(fineLogEvents.some(e => e.message.includes('Successfully generated AST'))).toBe(true); + }); + + it('When calling generateAst with valid Visualforce file, then AST is returned', async () => { + const engine: PmdEngine = new PmdEngine(DEFAULT_PMD_ENGINE_CONFIG); + const vfFile = path.join(TEST_DATA_FOLDER, 'samplePmdWorkspace', 'sampleViolations', 'VfUnescapeEl.page'); + + const results = await engine.generateAst('visualforce', vfFile); + + expect(results.file).toBe(vfFile); + expect(results.ast).toBeDefined(); + expect(results.ast).not.toBeNull(); + expect(results.ast!).toContain(' { + const engine: PmdEngine = new PmdEngine(DEFAULT_PMD_ENGINE_CONFIG); + const nonExistentFile = path.join(TEST_DATA_FOLDER, 'DoesNotExist.cls'); + + const logEvents: LogEvent[] = []; + engine.onEvent(EventType.LogEvent, (e: LogEvent) => logEvents.push(e)); + + const results = await engine.generateAst('apex', nonExistentFile); + + expect(results.file).toBe(nonExistentFile); + expect(results.ast).toBeFalsy(); + expect(results.error).toBeDefined(); + expect(results.error!.message).toContain('File not found'); + + // Check error log event + const errorLogEvents = logEvents.filter(e => e.logLevel === LogLevel.Error); + expect(errorLogEvents.length).toBeGreaterThan(0); + expect(errorLogEvents.some(e => e.message.includes('Failed to generate AST'))).toBe(true); + }); + + it('When calling generateAst with invalid language, then error is returned', async () => { + const engine: PmdEngine = new PmdEngine(DEFAULT_PMD_ENGINE_CONFIG); + const apexFile = path.join(TEST_DATA_FOLDER, 'samplePmdWorkspace', 'sampleViolations', 'AvoidDebugStatements.cls'); + + const results = await engine.generateAst('invalid_language', apexFile); + + expect(results.file).toBe(apexFile); + expect(results.ast).toBeFalsy(); + expect(results.error).toBeDefined(); + expect(results.error!.message).toContain('Language not supported'); + }); + + it('When calling generateAst with custom encoding, then AST is generated', async () => { + const engine: PmdEngine = new PmdEngine(DEFAULT_PMD_ENGINE_CONFIG); + const apexFile = path.join(TEST_DATA_FOLDER, 'samplePmdWorkspace', 'sampleViolations', 'AvoidDebugStatements.cls'); + + const results = await engine.generateAst('apex', apexFile, { encoding: 'UTF-8' }); + + expect(results.file).toBe(apexFile); + expect(results.ast).toBeDefined(); + expect(results.ast).not.toBeNull(); + expect(results.error).toBeUndefined(); + }); + + it('When calling generateAst with custom workingFolder, then working folder is not cleaned up', async () => { + const engine: PmdEngine = new PmdEngine(DEFAULT_PMD_ENGINE_CONFIG); + const apexFile = path.join(TEST_DATA_FOLDER, 'samplePmdWorkspace', 'sampleViolations', 'AvoidDebugStatements.cls'); + const customWorkingFolder = await fsPromises.mkdtemp(path.join(TEST_DATA_FOLDER, 'temp-ast-')); + + try { + const results = await engine.generateAst('apex', apexFile, { workingFolder: customWorkingFolder }); + + expect(results.ast).toBeDefined(); + // Working folder should still exist since we provided it + expect(await fsPromises.access(customWorkingFolder).then(() => true).catch(() => false)).toBe(true); + // Output file should exist + const outputFile = path.join(customWorkingFolder, 'astDumpResults.json'); + expect(await fsPromises.access(outputFile).then(() => true).catch(() => false)).toBe(true); + } finally { + // Clean up + await fsPromises.rm(customWorkingFolder, { recursive: true, force: true }); + } + }); +}); + function expectNoDashesAppearOutsideOfOurLanguageSpecificRules(ruleDescriptions: RuleDescription[]): void { for (const ruleDescription of ruleDescriptions) {