From 6d6e8975eb850a705ece7f9440c540303e0efd84 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Fri, 5 Jun 2026 09:48:55 +0530 Subject: [PATCH 1/4] NEW - Add ast-dump command for generating PMD AST output Introduces a new `code-analyzer ast-dump` command that generates and displays the Abstract Syntax Tree produced by PMD for a given source file. Supports Apex, Visualforce, HTML, XML, and JavaScript with JSON and XML output formats. --- messages/ast-dump-command.md | 85 +++++++++ src/Constants.ts | 3 +- src/commands/code-analyzer/ast-dump.ts | 78 ++++++++ src/lib/actions/AstDumpAction.ts | 250 +++++++++++++++++++++++++ src/lib/messages.ts | 1 + 5 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 messages/ast-dump-command.md create mode 100644 src/commands/code-analyzer/ast-dump.ts create mode 100644 src/lib/actions/AstDumpAction.ts diff --git a/messages/ast-dump-command.md b/messages/ast-dump-command.md new file mode 100644 index 000000000..cf1ace38a --- /dev/null +++ b/messages/ast-dump-command.md @@ -0,0 +1,85 @@ +# command.summary + +Dump the Abstract Syntax Tree (AST) for a given source file. + +# command.description + +Generate and display the AST that PMD produces when parsing a source file. This is useful for understanding how PMD interprets your code and for developing custom PMD rules. The command supports Apex, Visualforce, HTML, XML, and JavaScript files. + +# command.examples + +- Dump the AST for an Apex class file in JSON format (default): + + <%= config.bin %> <%= command.id %> --file ./force-app/main/default/classes/MyClass.cls + +- Dump the AST in XML format: + + <%= config.bin %> <%= command.id %> --file ./force-app/main/default/classes/MyClass.cls --format xml + +- Dump the AST and write the output to a file: + + <%= config.bin %> <%= command.id %> --file ./force-app/main/default/classes/MyClass.cls --output-file ast-output.json + +- Explicitly specify the language when the file extension is ambiguous: + + <%= config.bin %> <%= command.id %> --file ./src/myfile.html --language html + +- Use a custom configuration file to specify Java command path: + + <%= config.bin %> <%= command.id %> --file ./force-app/main/default/classes/MyClass.cls --config-file ./code-analyzer.yml + +# flags.file.summary + +Path to the source file to parse. + +# flags.file.description + +The file whose AST you want to generate. The file must exist and be a regular file (not a directory). If you don't specify the `--language` flag, the language is auto-detected from the file extension. + +# flags.language.summary + +Language of the source file. + +# flags.language.description + +Explicitly specify the language of the source file. Defaults to `apex`. Supported languages are: apex, visualforce, html, xml, and javascript. + +# flags.format.summary + +Output format for the AST. + +# flags.format.description + +Choose between `xml` (default) or `json`. The XML format outputs the raw PMD AST XML, preserving the full tree structure. The JSON format provides a flat array of nodes with names, attributes, parent, and ancestor information. + +# flags.output-file.summary + +Path to the file where the AST output is written. + +# flags.output-file.description + +If specified, the AST output is written to this file instead of being displayed in the terminal. The content format depends on the `--format` flag. + +# flags.config-file.summary + +Path to the configuration file used to customize engine settings. + +# flags.config-file.description + +Use a Code Analyzer configuration file to specify engine overrides such as a custom `java_command` path. If not specified, the default configuration is used. + +# error.fileNotFound + +File not found: %s + +# error.notRegularFile + +Path is not a regular file: %s + +# error.unsupportedLanguage + +Unable to determine language for file: %s. Use the --language flag to specify one of: apex, visualforce, html, xml, javascript. + +# error.parseFailed + +Failed to parse AST: %s diff --git a/src/Constants.ts b/src/Constants.ts index da75f4ead..ede951db4 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -15,5 +15,6 @@ export const CliTelemetryEvents = { export const CliCommands = { RUN: 'run', RULES: 'rules', - CONFIG: 'config' + CONFIG: 'config', + AST_DUMP: 'ast-dump' } diff --git a/src/commands/code-analyzer/ast-dump.ts b/src/commands/code-analyzer/ast-dump.ts new file mode 100644 index 000000000..7ea0fe7cd --- /dev/null +++ b/src/commands/code-analyzer/ast-dump.ts @@ -0,0 +1,78 @@ +import {Flags, SfCommand} from '@salesforce/sf-plugins-core'; +import {CodeAnalyzerConfigFactoryImpl} from '../../lib/factories/CodeAnalyzerConfigFactory.js'; +import {AstDumpAction, AstDumpDependencies, AstDumpInput, AstDumpOutput} from '../../lib/actions/AstDumpAction.js'; +import {BundleName, getMessage, getMessages} from '../../lib/messages.js'; + +export default class AstDumpCommand extends SfCommand { + public static readonly enableJsonFlag = true; + public static readonly summary = getMessage(BundleName.AstDumpCommand, 'command.summary'); + public static readonly description = getMessage(BundleName.AstDumpCommand, 'command.description'); + public static readonly examples = getMessages(BundleName.AstDumpCommand, 'command.examples'); + + public static readonly flags = { + file: Flags.file({ + summary: getMessage(BundleName.AstDumpCommand, 'flags.file.summary'), + description: getMessage(BundleName.AstDumpCommand, 'flags.file.description'), + required: true, + exists: true + }), + language: Flags.string({ + summary: getMessage(BundleName.AstDumpCommand, 'flags.language.summary'), + description: getMessage(BundleName.AstDumpCommand, 'flags.language.description'), + char: 'l', + options: ['apex', 'visualforce', 'html', 'xml', 'javascript'], + default: 'apex' + }), + format: Flags.string({ + summary: getMessage(BundleName.AstDumpCommand, 'flags.format.summary'), + description: getMessage(BundleName.AstDumpCommand, 'flags.format.description'), + options: ['json', 'xml'], + default: 'xml' + }), + 'output-file': Flags.string({ + summary: getMessage(BundleName.AstDumpCommand, 'flags.output-file.summary'), + description: getMessage(BundleName.AstDumpCommand, 'flags.output-file.description'), + }), + 'config-file': Flags.file({ + summary: getMessage(BundleName.AstDumpCommand, 'flags.config-file.summary'), + description: getMessage(BundleName.AstDumpCommand, 'flags.config-file.description'), + char: 'c', + exists: true + }) + }; + + public async run(): Promise { + const parsedFlags = (await this.parse(AstDumpCommand)).flags; + + const dependencies: AstDumpDependencies = { + configFactory: new CodeAnalyzerConfigFactoryImpl() + }; + + const action = AstDumpAction.createAction(dependencies); + + const input: AstDumpInput = { + file: parsedFlags.file, + language: parsedFlags.language, + format: parsedFlags.format as 'json' | 'xml', + 'output-file': parsedFlags['output-file'], + 'config-file': parsedFlags['config-file'] + }; + + const output: AstDumpOutput = await action.execute(input); + + if (output.status === 'error') { + this.error(output.message); + } + + // Display output + if (!input['output-file']) { + if (input.format === 'xml') { + this.log((output as { ast: string }).ast); + } else { + this.log(JSON.stringify(output, null, 2)); + } + } else { + this.log(`AST written to: ${input['output-file']}`); + } + } +} diff --git a/src/lib/actions/AstDumpAction.ts b/src/lib/actions/AstDumpAction.ts new file mode 100644 index 000000000..6f23a29b3 --- /dev/null +++ b/src/lib/actions/AstDumpAction.ts @@ -0,0 +1,250 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import {CodeAnalyzerConfig} from '@salesforce/code-analyzer-core'; +import {PmdEngine} from '@salesforce/code-analyzer-pmd-engine'; +import type {PmdAstDumpResults, GenerateAstOptions} from '@salesforce/code-analyzer-pmd-engine'; +import {CodeAnalyzerConfigFactory} from '../factories/CodeAnalyzerConfigFactory.js'; +import {BundleName, getMessage} from '../messages.js'; + +export type AstDumpInput = { + file: string; + language?: string; + format: 'json' | 'xml'; + 'output-file'?: string; + 'config-file'?: string; +} + +export type AstNode = { + nodeName: string; + attributes: Record; + parent: string | null; + ancestors: string[]; +} + +export type AstDumpJsonOutput = { + status: 'success'; + file: string; + language: string; + totalNodes: number; + nodes: AstNode[]; +} + +export type AstDumpXmlOutput = { + status: 'success'; + file: string; + language: string; + ast: string; +} + +export type AstDumpErrorOutput = { + status: 'error'; + message: string; +} + +export type AstDumpOutput = AstDumpJsonOutput | AstDumpXmlOutput | AstDumpErrorOutput; + +export type AstDumpDependencies = { + configFactory: CodeAnalyzerConfigFactory; +} + +const LANGUAGE_MAP: Record = { + '.cls': 'apex', + '.trigger': 'apex', + '.page': 'visualforce', + '.component': 'visualforce', + '.html': 'html', + '.htm': 'html', + '.xml': 'xml', + '.js': 'javascript', +}; + +const SUPPORTED_LANGUAGES = ['apex', 'visualforce', 'html', 'xml', 'javascript']; + +const MAX_FILE_SIZE_BYTES = 1_000_000; // 1MB + +export class AstDumpAction { + private readonly dependencies: AstDumpDependencies; + + constructor(dependencies: AstDumpDependencies) { + this.dependencies = dependencies; + } + + public async execute(input: AstDumpInput): Promise { + // Validate file exists + const filePath = path.resolve(input.file); + if (!fs.existsSync(filePath)) { + return {status: 'error', message: getMessage(BundleName.AstDumpCommand, 'error.fileNotFound', [filePath])}; + } + if (!fs.statSync(filePath).isFile()) { + return {status: 'error', message: getMessage(BundleName.AstDumpCommand, 'error.notRegularFile', [filePath])}; + } + + // Check file size + const fileSize = fs.statSync(filePath).size; + if (fileSize > MAX_FILE_SIZE_BYTES) { + return {status: 'error', message: `File exceeds maximum size of ${MAX_FILE_SIZE_BYTES} bytes (${fileSize} bytes). Provide a smaller snippet.`}; + } + + // Determine language + const language = input.language || this.detectLanguage(filePath); + if (!language) { + return {status: 'error', message: getMessage(BundleName.AstDumpCommand, 'error.unsupportedLanguage', [filePath])}; + } + if (!SUPPORTED_LANGUAGES.includes(language)) { + return {status: 'error', message: getMessage(BundleName.AstDumpCommand, 'error.unsupportedLanguage', [filePath])}; + } + + // Get config for java_command resolution + const config: CodeAnalyzerConfig = this.dependencies.configFactory.create(input['config-file']); + const pmdConfig = this.extractPmdConfig(config); + + // Create PmdEngine and generate AST + const pmdEngine = new PmdEngine(pmdConfig as ConstructorParameters[0]); + const options: GenerateAstOptions = {encoding: 'UTF-8'}; + + let results: PmdAstDumpResults; + try { + results = await pmdEngine.generateAst(language, filePath, options); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + return {status: 'error', message: getMessage(BundleName.AstDumpCommand, 'error.parseFailed', [errMsg])}; + } + + if (results.error) { + return {status: 'error', message: getMessage(BundleName.AstDumpCommand, 'error.parseFailed', [results.error.message])}; + } + + if (!results.ast) { + return {status: 'error', message: getMessage(BundleName.AstDumpCommand, 'error.parseFailed', ['No AST generated'])}; + } + + // Format output + let output: AstDumpOutput; + if (input.format === 'xml') { + output = {status: 'success', file: filePath, language, ast: results.ast}; + } else { + const nodes = this.parseAstXmlToNodes(results.ast); + output = {status: 'success', file: filePath, language, totalNodes: nodes.length, nodes}; + } + + // Write to file or return + if (input['output-file']) { + const outputPath = path.resolve(input['output-file']); + const content = input.format === 'xml' ? (output as AstDumpXmlOutput).ast : JSON.stringify(output, null, 2); + fs.writeFileSync(outputPath, content, 'utf-8'); + } + + return output; + } + + private detectLanguage(filePath: string): string | undefined { + const ext = path.extname(filePath).toLowerCase(); + return LANGUAGE_MAP[ext]; + } + + private extractPmdConfig(config: CodeAnalyzerConfig): Record { + const overrides = config.getEngineOverridesFor('pmd') as Record; + return { + java_command: overrides.java_command || 'java', + java_classpath_entries: overrides.java_classpath_entries || [], + custom_rulesets: overrides.custom_rulesets || [], + rule_languages: ['apex', 'visualforce', 'xml', 'html', 'javascript'], + file_extensions: { + apex: ['.cls', '.trigger'], + visualforce: ['.page', '.component'], + xml: ['.xml'], + html: ['.html', '.htm'], + javascript: ['.js'] + } + }; + } + + /** + * Parse PMD AST XML into a flat list of nodes with ancestry information. + * Each node gets: nodeName, attributes (key-value pairs), parent, ancestors chain. + */ + private parseAstXmlToNodes(xml: string): AstNode[] { + const nodes: AstNode[] = []; + this.traverseXml(xml, nodes); + return nodes; + } + + /** + * Simple XML parser for PMD AST output. + * PMD AST XML has a predictable structure: nested elements with attributes. + * We parse it using regex-based tag extraction (safe for well-formed PMD output). + */ + private traverseXml(xml: string, nodes: AstNode[]): void { + const tagRegex = /<(\w+)([^>]*?)(\/>|>)/g; + const closeTagRegex = /<\/(\w+)>/g; + + const ancestorStack: string[] = []; + let match: RegExpExecArray | null; + + // Build a simple event-based parser + const events: Array<{type: 'open' | 'close' | 'selfclose'; name: string; attrs: string; pos: number}> = []; + + // Find all opening/self-closing tags + tagRegex.lastIndex = 0; + while ((match = tagRegex.exec(xml)) !== null) { + const [, name, attrs, closing] = match; + if (name === '?xml') continue; // Skip XML declaration + events.push({ + type: closing === '/>' ? 'selfclose' : 'open', + name, + attrs: attrs || '', + pos: match.index + }); + } + + // Find all closing tags + closeTagRegex.lastIndex = 0; + while ((match = closeTagRegex.exec(xml)) !== null) { + events.push({type: 'close', name: match[1], attrs: '', pos: match.index}); + } + + // Sort by position in document + events.sort((a, b) => a.pos - b.pos); + + // Process events in order + for (const event of events) { + if (event.type === 'open') { + const attributes = this.parseAttributes(event.attrs); + nodes.push({ + nodeName: event.name, + attributes, + parent: ancestorStack.length > 0 ? ancestorStack[ancestorStack.length - 1] : null, + ancestors: [...ancestorStack] + }); + ancestorStack.push(event.name); + } else if (event.type === 'selfclose') { + const attributes = this.parseAttributes(event.attrs); + nodes.push({ + nodeName: event.name, + attributes, + parent: ancestorStack.length > 0 ? ancestorStack[ancestorStack.length - 1] : null, + ancestors: [...ancestorStack] + }); + } else if (event.type === 'close') { + ancestorStack.pop(); + } + } + } + + /** + * Parse XML attributes from a string like: Name="value" Other="value2" + */ + private parseAttributes(attrString: string): Record { + const attrs: Record = {}; + const attrRegex = /(\w+)\s*=\s*(['"])(.*?)\2/g; + let match: RegExpExecArray | null; + while ((match = attrRegex.exec(attrString)) !== null) { + attrs[match[1]] = match[3]; + } + return attrs; + } + + public static createAction(dependencies: AstDumpDependencies): AstDumpAction { + return new AstDumpAction(dependencies); + } +} diff --git a/src/lib/messages.ts b/src/lib/messages.ts index c270cb1cd..55aed1b9b 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -6,6 +6,7 @@ Messages.importMessagesDirectory(import.meta.dirname); export enum BundleName { ActionSummaryViewer = 'action-summary-viewer', + AstDumpCommand = 'ast-dump-command', ConfigCommand = 'config-command', ConfigModel = 'config-model', ConfigWriter = 'config-writer', From 5cffe8c8c1530e878bcc353adf15ddb0287dfa45 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Fri, 5 Jun 2026 09:57:19 +0530 Subject: [PATCH 2/4] remove config setting --- messages/ast-dump-command.md | 12 ----------- src/commands/code-analyzer/ast-dump.ts | 18 +++------------- src/lib/actions/AstDumpAction.ts | 29 +++++++------------------- 3 files changed, 10 insertions(+), 49 deletions(-) diff --git a/messages/ast-dump-command.md b/messages/ast-dump-command.md index cf1ace38a..c14c50ef8 100644 --- a/messages/ast-dump-command.md +++ b/messages/ast-dump-command.md @@ -24,10 +24,6 @@ Generate and display the AST that PMD produces when parsing a source file. This <%= config.bin %> <%= command.id %> --file ./src/myfile.html --language html -- Use a custom configuration file to specify Java command path: - - <%= config.bin %> <%= command.id %> --file ./force-app/main/default/classes/MyClass.cls --config-file ./code-analyzer.yml - # flags.file.summary Path to the source file to parse. @@ -60,14 +56,6 @@ Path to the file where the AST output is written. If specified, the AST output is written to this file instead of being displayed in the terminal. The content format depends on the `--format` flag. -# flags.config-file.summary - -Path to the configuration file used to customize engine settings. - -# flags.config-file.description - -Use a Code Analyzer configuration file to specify engine overrides such as a custom `java_command` path. If not specified, the default configuration is used. - # error.fileNotFound File not found: %s diff --git a/src/commands/code-analyzer/ast-dump.ts b/src/commands/code-analyzer/ast-dump.ts index 7ea0fe7cd..0e24dfd0a 100644 --- a/src/commands/code-analyzer/ast-dump.ts +++ b/src/commands/code-analyzer/ast-dump.ts @@ -1,6 +1,5 @@ import {Flags, SfCommand} from '@salesforce/sf-plugins-core'; -import {CodeAnalyzerConfigFactoryImpl} from '../../lib/factories/CodeAnalyzerConfigFactory.js'; -import {AstDumpAction, AstDumpDependencies, AstDumpInput, AstDumpOutput} from '../../lib/actions/AstDumpAction.js'; +import {AstDumpAction, AstDumpInput, AstDumpOutput} from '../../lib/actions/AstDumpAction.js'; import {BundleName, getMessage, getMessages} from '../../lib/messages.js'; export default class AstDumpCommand extends SfCommand { @@ -32,30 +31,19 @@ export default class AstDumpCommand extends SfCommand { 'output-file': Flags.string({ summary: getMessage(BundleName.AstDumpCommand, 'flags.output-file.summary'), description: getMessage(BundleName.AstDumpCommand, 'flags.output-file.description'), - }), - 'config-file': Flags.file({ - summary: getMessage(BundleName.AstDumpCommand, 'flags.config-file.summary'), - description: getMessage(BundleName.AstDumpCommand, 'flags.config-file.description'), - char: 'c', - exists: true }) }; public async run(): Promise { const parsedFlags = (await this.parse(AstDumpCommand)).flags; - const dependencies: AstDumpDependencies = { - configFactory: new CodeAnalyzerConfigFactoryImpl() - }; - - const action = AstDumpAction.createAction(dependencies); + const action = AstDumpAction.createAction(); const input: AstDumpInput = { file: parsedFlags.file, language: parsedFlags.language, format: parsedFlags.format as 'json' | 'xml', - 'output-file': parsedFlags['output-file'], - 'config-file': parsedFlags['config-file'] + 'output-file': parsedFlags['output-file'] }; const output: AstDumpOutput = await action.execute(input); diff --git a/src/lib/actions/AstDumpAction.ts b/src/lib/actions/AstDumpAction.ts index 6f23a29b3..505d7deaf 100644 --- a/src/lib/actions/AstDumpAction.ts +++ b/src/lib/actions/AstDumpAction.ts @@ -1,9 +1,7 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; -import {CodeAnalyzerConfig} from '@salesforce/code-analyzer-core'; import {PmdEngine} from '@salesforce/code-analyzer-pmd-engine'; import type {PmdAstDumpResults, GenerateAstOptions} from '@salesforce/code-analyzer-pmd-engine'; -import {CodeAnalyzerConfigFactory} from '../factories/CodeAnalyzerConfigFactory.js'; import {BundleName, getMessage} from '../messages.js'; export type AstDumpInput = { @@ -11,7 +9,6 @@ export type AstDumpInput = { language?: string; format: 'json' | 'xml'; 'output-file'?: string; - 'config-file'?: string; } export type AstNode = { @@ -43,9 +40,6 @@ export type AstDumpErrorOutput = { export type AstDumpOutput = AstDumpJsonOutput | AstDumpXmlOutput | AstDumpErrorOutput; -export type AstDumpDependencies = { - configFactory: CodeAnalyzerConfigFactory; -} const LANGUAGE_MAP: Record = { '.cls': 'apex', @@ -63,12 +57,6 @@ const SUPPORTED_LANGUAGES = ['apex', 'visualforce', 'html', 'xml', 'javascript'] const MAX_FILE_SIZE_BYTES = 1_000_000; // 1MB export class AstDumpAction { - private readonly dependencies: AstDumpDependencies; - - constructor(dependencies: AstDumpDependencies) { - this.dependencies = dependencies; - } - public async execute(input: AstDumpInput): Promise { // Validate file exists const filePath = path.resolve(input.file); @@ -94,9 +82,7 @@ export class AstDumpAction { return {status: 'error', message: getMessage(BundleName.AstDumpCommand, 'error.unsupportedLanguage', [filePath])}; } - // Get config for java_command resolution - const config: CodeAnalyzerConfig = this.dependencies.configFactory.create(input['config-file']); - const pmdConfig = this.extractPmdConfig(config); + const pmdConfig = this.getDefaultPmdConfig(); // Create PmdEngine and generate AST const pmdEngine = new PmdEngine(pmdConfig as ConstructorParameters[0]); @@ -142,12 +128,11 @@ export class AstDumpAction { return LANGUAGE_MAP[ext]; } - private extractPmdConfig(config: CodeAnalyzerConfig): Record { - const overrides = config.getEngineOverridesFor('pmd') as Record; + private getDefaultPmdConfig(): Record { return { - java_command: overrides.java_command || 'java', - java_classpath_entries: overrides.java_classpath_entries || [], - custom_rulesets: overrides.custom_rulesets || [], + java_command: 'java', + java_classpath_entries: [], + custom_rulesets: [], rule_languages: ['apex', 'visualforce', 'xml', 'html', 'javascript'], file_extensions: { apex: ['.cls', '.trigger'], @@ -244,7 +229,7 @@ export class AstDumpAction { return attrs; } - public static createAction(dependencies: AstDumpDependencies): AstDumpAction { - return new AstDumpAction(dependencies); + public static createAction(): AstDumpAction { + return new AstDumpAction(); } } From a9243b0f7225edc5e6f4fce4621a3925ce24f381 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Fri, 5 Jun 2026 10:24:27 +0530 Subject: [PATCH 3/4] add tests --- test/commands/code-analyzer/ast-dump.test.ts | 201 +++++++++++++ test/lib/actions/AstDumpAction.test.ts | 293 +++++++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 test/commands/code-analyzer/ast-dump.test.ts create mode 100644 test/lib/actions/AstDumpAction.test.ts diff --git a/test/commands/code-analyzer/ast-dump.test.ts b/test/commands/code-analyzer/ast-dump.test.ts new file mode 100644 index 000000000..030f009fe --- /dev/null +++ b/test/commands/code-analyzer/ast-dump.test.ts @@ -0,0 +1,201 @@ +import * as path from 'node:path'; +import {Config, settings} from '@oclif/core'; +import AstDumpCommand from '../../../src/commands/code-analyzer/ast-dump.js'; +import {AstDumpAction, AstDumpInput} from '../../../src/lib/actions/AstDumpAction.js'; +import {ConsoleOuputInterceptor} from '../../test-utils.js'; + +const rootFolderWithPackageJson: string = path.join(__dirname, '..', '..', '..'); + +settings.enableAutoTranspile = false; +const config: Config = new Config({root: rootFolderWithPackageJson}); + +async function runAstDumpCommand(userArgs: string[]): Promise { + const command: AstDumpCommand = new AstDumpCommand(userArgs, config); + return await command.run(); +} + +describe('`code-analyzer ast-dump` unit tests', () => { + beforeAll(async () => { + await config.load(); + }); + + let executeSpy: ReturnType; + let createActionSpy: ReturnType; + let receivedActionInput!: AstDumpInput; + + beforeEach(() => { + executeSpy = vi.spyOn(AstDumpAction.prototype, 'execute').mockImplementation((input) => { + receivedActionInput = input; + return Promise.resolve({status: 'success', file: input.file, language: 'apex', ast: ''}); + }); + createActionSpy = vi.spyOn(AstDumpAction, 'createAction').mockImplementation(() => { + return new AstDumpAction(); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('--file', () => { + it('Accepts a real file', async () => { + const inputValue = 'package.json'; + await runAstDumpCommand(['--file', inputValue]); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('file', inputValue); + }); + + it('Rejects non-existent file', async () => { + const inputValue = 'definitelyFakeFile.cls'; + const executionPromise = runAstDumpCommand(['--file', inputValue]); + await expect(executionPromise).rejects.toThrow(); + expect(executeSpy).not.toHaveBeenCalled(); + }); + + it('Is required', async () => { + const executionPromise = runAstDumpCommand([]); + await expect(executionPromise).rejects.toThrow(); + expect(executeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('--language', () => { + it('Accepts valid language value', async () => { + await runAstDumpCommand(['--file', 'package.json', '--language', 'apex']); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('language', 'apex'); + }); + + it('Accepts all supported languages', async () => { + for (const lang of ['apex', 'visualforce', 'html', 'xml', 'javascript']) { + vi.restoreAllMocks(); + executeSpy = vi.spyOn(AstDumpAction.prototype, 'execute').mockImplementation((input) => { + receivedActionInput = input; + return Promise.resolve({status: 'success', file: input.file, language: lang, ast: ''}); + }); + createActionSpy = vi.spyOn(AstDumpAction, 'createAction').mockImplementation(() => new AstDumpAction()); + + await runAstDumpCommand(['--file', 'package.json', '--language', lang]); + expect(receivedActionInput).toHaveProperty('language', lang); + } + }); + + it('Rejects invalid language value', async () => { + const executionPromise = runAstDumpCommand(['--file', 'package.json', '--language', 'python']); + await expect(executionPromise).rejects.toThrow(); + expect(executeSpy).not.toHaveBeenCalled(); + }); + + it('Defaults to apex', async () => { + await runAstDumpCommand(['--file', 'package.json']); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('language', 'apex'); + }); + + it('Can be referenced by shortname -l', async () => { + await runAstDumpCommand(['--file', 'package.json', '-l', 'html']); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('language', 'html'); + }); + }); + + describe('--format', () => { + it('Accepts xml format', async () => { + await runAstDumpCommand(['--file', 'package.json', '--format', 'xml']); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('format', 'xml'); + }); + + it('Accepts json format', async () => { + executeSpy.mockImplementation((input) => { + receivedActionInput = input; + return Promise.resolve({status: 'success', file: input.file, language: 'apex', totalNodes: 0, nodes: []}); + }); + await runAstDumpCommand(['--file', 'package.json', '--format', 'json']); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('format', 'json'); + }); + + it('Rejects invalid format', async () => { + const executionPromise = runAstDumpCommand(['--file', 'package.json', '--format', 'csv']); + await expect(executionPromise).rejects.toThrow(); + expect(executeSpy).not.toHaveBeenCalled(); + }); + + it('Defaults to xml', async () => { + await runAstDumpCommand(['--file', 'package.json']); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('format', 'xml'); + }); + }); + + describe('--output-file', () => { + it('Can be supplied with a value', async () => { + await runAstDumpCommand(['--file', 'package.json', '--output-file', './out.xml']); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('output-file', './out.xml'); + }); + + it('Is unused if not specified', async () => { + await runAstDumpCommand(['--file', 'package.json']); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput['output-file']).toBeUndefined(); + }); + }); + + describe('Output display', () => { + it('Logs XML output directly when format is xml', async () => { + const astXml = ''; + executeSpy.mockImplementation((input) => { + receivedActionInput = input; + return Promise.resolve({status: 'success', file: input.file, language: 'apex', ast: astXml}); + }); + + const outputInterceptor = new ConsoleOuputInterceptor(); + try { + outputInterceptor.start(); + await runAstDumpCommand(['--file', 'package.json', '--format', 'xml']); + } finally { + outputInterceptor.stop(); + } + expect(outputInterceptor.out).toContain(astXml); + }); + + it('Logs JSON output when format is json', async () => { + executeSpy.mockImplementation((input) => { + receivedActionInput = input; + return Promise.resolve({status: 'success', file: input.file, language: 'apex', totalNodes: 1, nodes: [{nodeName: 'Root', attributes: {}, parent: null, ancestors: []}]}); + }); + + const outputInterceptor = new ConsoleOuputInterceptor(); + try { + outputInterceptor.start(); + await runAstDumpCommand(['--file', 'package.json', '--format', 'json']); + } finally { + outputInterceptor.stop(); + } + expect(outputInterceptor.out).toContain('"nodeName"'); + expect(outputInterceptor.out).toContain('"Root"'); + }); + + it('Logs file path message when output-file is specified', async () => { + const outputInterceptor = new ConsoleOuputInterceptor(); + try { + outputInterceptor.start(); + await runAstDumpCommand(['--file', 'package.json', '--output-file', './my-output.xml']); + } finally { + outputInterceptor.stop(); + } + expect(outputInterceptor.out).toContain('AST written to: ./my-output.xml'); + }); + + it('Throws error when action returns error status', async () => { + executeSpy.mockImplementation(() => { + return Promise.resolve({status: 'error', message: 'Something went wrong'}); + }); + + const executionPromise = runAstDumpCommand(['--file', 'package.json']); + await expect(executionPromise).rejects.toThrow('Something went wrong'); + }); + }); +}); diff --git a/test/lib/actions/AstDumpAction.test.ts b/test/lib/actions/AstDumpAction.test.ts new file mode 100644 index 000000000..3d80018b8 --- /dev/null +++ b/test/lib/actions/AstDumpAction.test.ts @@ -0,0 +1,293 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import {AstDumpAction, AstDumpInput, AstDumpOutput, AstDumpJsonOutput, AstDumpXmlOutput, AstDumpErrorOutput} from '../../../src/lib/actions/AstDumpAction.js'; + +const { mockGenerateAst } = vi.hoisted(() => { + return { mockGenerateAst: vi.fn() }; +}); + +vi.mock('@salesforce/code-analyzer-pmd-engine', () => { + return { + PmdEngine: class MockPmdEngine { + generateAst = mockGenerateAst; + } + }; +}); + +const SAMPLE_AST_XML = ` + + + + + + +`; + +const PATH_TO_SAMPLE_CODE = path.resolve('test', 'sample-code'); +const PATH_TO_FILE_A = path.resolve(PATH_TO_SAMPLE_CODE, 'fileA.cls'); + +describe('AstDumpAction tests', () => { + let action: AstDumpAction; + + beforeEach(() => { + mockGenerateAst.mockReset(); + action = AstDumpAction.createAction(); + }); + + describe('File validation', () => { + it('Returns error when file does not exist', async () => { + const input: AstDumpInput = { + file: '/nonexistent/path/file.cls', + language: 'apex', + format: 'xml' + }; + + const output = await action.execute(input) as AstDumpErrorOutput; + + expect(output.status).toBe('error'); + expect(output.message).toContain('File not found'); + }); + + it('Returns error when path is a directory', async () => { + const input: AstDumpInput = { + file: PATH_TO_SAMPLE_CODE, + language: 'apex', + format: 'xml' + }; + + const output = await action.execute(input) as AstDumpErrorOutput; + + expect(output.status).toBe('error'); + expect(output.message).toContain('not a regular file'); + }); + + it('Returns error when file exceeds max size', async () => { + const tmpFile = path.join(os.tmpdir(), 'ast-dump-test-large-file.cls'); + fs.writeFileSync(tmpFile, 'x'.repeat(1_000_001)); + try { + const input: AstDumpInput = { + file: tmpFile, + language: 'apex', + format: 'xml' + }; + + const output = await action.execute(input) as AstDumpErrorOutput; + + expect(output.status).toBe('error'); + expect(output.message).toContain('exceeds maximum size'); + } finally { + fs.unlinkSync(tmpFile); + } + }); + }); + + describe('Language detection', () => { + it('Detects apex from .cls extension', async () => { + mockGenerateAst.mockResolvedValue({file: PATH_TO_FILE_A, ast: SAMPLE_AST_XML, error: null}); + + const input: AstDumpInput = { + file: PATH_TO_FILE_A, + format: 'xml' + }; + + const output = await action.execute(input) as AstDumpXmlOutput; + + expect(output.status).toBe('success'); + expect(output.language).toBe('apex'); + expect(mockGenerateAst).toHaveBeenCalledWith('apex', expect.any(String), expect.any(Object)); + }); + + it('Uses explicitly provided language over auto-detection', async () => { + mockGenerateAst.mockResolvedValue({file: PATH_TO_FILE_A, ast: SAMPLE_AST_XML, error: null}); + + const input: AstDumpInput = { + file: PATH_TO_FILE_A, + language: 'visualforce', + format: 'xml' + }; + + const output = await action.execute(input) as AstDumpXmlOutput; + + expect(output.status).toBe('success'); + expect(output.language).toBe('visualforce'); + expect(mockGenerateAst).toHaveBeenCalledWith('visualforce', expect.any(String), expect.any(Object)); + }); + + it('Returns error for unsupported file extension when no language specified', async () => { + const tmpFile = path.join(os.tmpdir(), 'ast-dump-test-file.py'); + fs.writeFileSync(tmpFile, 'print("hello")'); + try { + const input: AstDumpInput = { + file: tmpFile, + format: 'xml' + }; + + const output = await action.execute(input) as AstDumpErrorOutput; + + expect(output.status).toBe('error'); + expect(output.message).toContain('Unable to determine language'); + } finally { + fs.unlinkSync(tmpFile); + } + }); + }); + + describe('XML format output', () => { + it('Returns raw AST XML on success', async () => { + mockGenerateAst.mockResolvedValue({file: PATH_TO_FILE_A, ast: SAMPLE_AST_XML, error: null}); + + const input: AstDumpInput = { + file: PATH_TO_FILE_A, + language: 'apex', + format: 'xml' + }; + + const output = await action.execute(input) as AstDumpXmlOutput; + + expect(output.status).toBe('success'); + expect(output.ast).toBe(SAMPLE_AST_XML); + expect(output.file).toBe(path.resolve(PATH_TO_FILE_A)); + expect(output.language).toBe('apex'); + }); + }); + + describe('JSON format output', () => { + it('Parses AST XML into nodes with ancestry', async () => { + mockGenerateAst.mockResolvedValue({file: PATH_TO_FILE_A, ast: SAMPLE_AST_XML, error: null}); + + const input: AstDumpInput = { + file: PATH_TO_FILE_A, + language: 'apex', + format: 'json' + }; + + const output = await action.execute(input) as AstDumpJsonOutput; + + expect(output.status).toBe('success'); + expect(output.totalNodes).toBeGreaterThan(0); + expect(output.nodes).toBeInstanceOf(Array); + + const compilationUnit = output.nodes.find(n => n.nodeName === 'CompilationUnit'); + expect(compilationUnit).toBeDefined(); + expect(compilationUnit!.parent).toBeNull(); + expect(compilationUnit!.ancestors).toEqual([]); + + const classDecl = output.nodes.find(n => n.nodeName === 'ClassDeclaration'); + expect(classDecl).toBeDefined(); + expect(classDecl!.attributes['SimpleName']).toBe('MyClass'); + expect(classDecl!.ancestors).toContain('CompilationUnit'); + + const methodDecl = output.nodes.find(n => n.nodeName === 'MethodDeclaration'); + expect(methodDecl).toBeDefined(); + expect(methodDecl!.parent).toBe('ClassDeclaration'); + expect(methodDecl!.attributes['Name']).toBe('myMethod'); + }); + }); + + describe('Output file writing', () => { + it('Writes XML output to file', async () => { + mockGenerateAst.mockResolvedValue({file: PATH_TO_FILE_A, ast: SAMPLE_AST_XML, error: null}); + const outputFile = path.join(os.tmpdir(), 'ast-dump-test-output.xml'); + + try { + const input: AstDumpInput = { + file: PATH_TO_FILE_A, + language: 'apex', + format: 'xml', + 'output-file': outputFile + }; + + const output = await action.execute(input) as AstDumpXmlOutput; + + expect(output.status).toBe('success'); + const written = fs.readFileSync(outputFile, 'utf-8'); + expect(written).toBe(SAMPLE_AST_XML); + } finally { + if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); + } + }); + + it('Writes JSON output to file', async () => { + mockGenerateAst.mockResolvedValue({file: PATH_TO_FILE_A, ast: SAMPLE_AST_XML, error: null}); + const outputFile = path.join(os.tmpdir(), 'ast-dump-test-output.json'); + + try { + const input: AstDumpInput = { + file: PATH_TO_FILE_A, + language: 'apex', + format: 'json', + 'output-file': outputFile + }; + + const output = await action.execute(input) as AstDumpJsonOutput; + + expect(output.status).toBe('success'); + const written = JSON.parse(fs.readFileSync(outputFile, 'utf-8')); + expect(written.nodes).toBeInstanceOf(Array); + expect(written.totalNodes).toBe(output.totalNodes); + } finally { + if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); + } + }); + }); + + describe('Error handling', () => { + it('Returns error when PmdEngine throws', async () => { + mockGenerateAst.mockRejectedValue(new Error('Java not found')); + + const input: AstDumpInput = { + file: PATH_TO_FILE_A, + language: 'apex', + format: 'xml' + }; + + const output = await action.execute(input) as AstDumpErrorOutput; + + expect(output.status).toBe('error'); + expect(output.message).toContain('Failed to parse AST'); + expect(output.message).toContain('Java not found'); + }); + + it('Returns error when results contain an error', async () => { + mockGenerateAst.mockResolvedValue({ + file: PATH_TO_FILE_A, + ast: null, + error: {message: 'Parse error at line 5'} + }); + + const input: AstDumpInput = { + file: PATH_TO_FILE_A, + language: 'apex', + format: 'xml' + }; + + const output = await action.execute(input) as AstDumpErrorOutput; + + expect(output.status).toBe('error'); + expect(output.message).toContain('Parse error at line 5'); + }); + + it('Returns error when AST is null without error', async () => { + mockGenerateAst.mockResolvedValue({file: PATH_TO_FILE_A, ast: null, error: null}); + + const input: AstDumpInput = { + file: PATH_TO_FILE_A, + language: 'apex', + format: 'xml' + }; + + const output = await action.execute(input) as AstDumpErrorOutput; + + expect(output.status).toBe('error'); + expect(output.message).toContain('No AST generated'); + }); + }); + + describe('createAction', () => { + it('Creates an instance of AstDumpAction', () => { + const instance = AstDumpAction.createAction(); + expect(instance).toBeInstanceOf(AstDumpAction); + }); + }); +}); From ac7134d2239f17e415187fd67fde1923badbaf5e Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Fri, 5 Jun 2026 10:54:59 +0530 Subject: [PATCH 4/4] add test case --- test/commands/code-analyzer/ast-dump.test.ts | 11 +++++------ test/lib/actions/AstDumpAction.test.ts | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/test/commands/code-analyzer/ast-dump.test.ts b/test/commands/code-analyzer/ast-dump.test.ts index 030f009fe..13936e774 100644 --- a/test/commands/code-analyzer/ast-dump.test.ts +++ b/test/commands/code-analyzer/ast-dump.test.ts @@ -20,7 +20,6 @@ describe('`code-analyzer ast-dump` unit tests', () => { }); let executeSpy: ReturnType; - let createActionSpy: ReturnType; let receivedActionInput!: AstDumpInput; beforeEach(() => { @@ -28,7 +27,7 @@ describe('`code-analyzer ast-dump` unit tests', () => { receivedActionInput = input; return Promise.resolve({status: 'success', file: input.file, language: 'apex', ast: ''}); }); - createActionSpy = vi.spyOn(AstDumpAction, 'createAction').mockImplementation(() => { + vi.spyOn(AstDumpAction, 'createAction').mockImplementation(() => { return new AstDumpAction(); }); }); @@ -73,7 +72,7 @@ describe('`code-analyzer ast-dump` unit tests', () => { receivedActionInput = input; return Promise.resolve({status: 'success', file: input.file, language: lang, ast: ''}); }); - createActionSpy = vi.spyOn(AstDumpAction, 'createAction').mockImplementation(() => new AstDumpAction()); + vi.spyOn(AstDumpAction, 'createAction').mockImplementation(() => new AstDumpAction()); await runAstDumpCommand(['--file', 'package.json', '--language', lang]); expect(receivedActionInput).toHaveProperty('language', lang); @@ -107,7 +106,7 @@ describe('`code-analyzer ast-dump` unit tests', () => { }); it('Accepts json format', async () => { - executeSpy.mockImplementation((input) => { + executeSpy.mockImplementation((input: AstDumpInput) => { receivedActionInput = input; return Promise.resolve({status: 'success', file: input.file, language: 'apex', totalNodes: 0, nodes: []}); }); @@ -146,7 +145,7 @@ describe('`code-analyzer ast-dump` unit tests', () => { describe('Output display', () => { it('Logs XML output directly when format is xml', async () => { const astXml = ''; - executeSpy.mockImplementation((input) => { + executeSpy.mockImplementation((input: AstDumpInput) => { receivedActionInput = input; return Promise.resolve({status: 'success', file: input.file, language: 'apex', ast: astXml}); }); @@ -162,7 +161,7 @@ describe('`code-analyzer ast-dump` unit tests', () => { }); it('Logs JSON output when format is json', async () => { - executeSpy.mockImplementation((input) => { + executeSpy.mockImplementation((input: AstDumpInput) => { receivedActionInput = input; return Promise.resolve({status: 'success', file: input.file, language: 'apex', totalNodes: 1, nodes: [{nodeName: 'Root', attributes: {}, parent: null, ancestors: []}]}); }); diff --git a/test/lib/actions/AstDumpAction.test.ts b/test/lib/actions/AstDumpAction.test.ts index 3d80018b8..b7ff8ad7a 100644 --- a/test/lib/actions/AstDumpAction.test.ts +++ b/test/lib/actions/AstDumpAction.test.ts @@ -1,7 +1,7 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import * as os from 'node:os'; -import {AstDumpAction, AstDumpInput, AstDumpOutput, AstDumpJsonOutput, AstDumpXmlOutput, AstDumpErrorOutput} from '../../../src/lib/actions/AstDumpAction.js'; +import {AstDumpAction, AstDumpInput, AstDumpJsonOutput, AstDumpXmlOutput, AstDumpErrorOutput} from '../../../src/lib/actions/AstDumpAction.js'; const { mockGenerateAst } = vi.hoisted(() => { return { mockGenerateAst: vi.fn() };