diff --git a/messages/ast-dump-command.md b/messages/ast-dump-command.md new file mode 100644 index 000000000..c14c50ef8 --- /dev/null +++ b/messages/ast-dump-command.md @@ -0,0 +1,73 @@ +# 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 + +# 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. + +# 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..0e24dfd0a --- /dev/null +++ b/src/commands/code-analyzer/ast-dump.ts @@ -0,0 +1,66 @@ +import {Flags, SfCommand} from '@salesforce/sf-plugins-core'; +import {AstDumpAction, 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'), + }) + }; + + public async run(): Promise { + const parsedFlags = (await this.parse(AstDumpCommand)).flags; + + const action = AstDumpAction.createAction(); + + const input: AstDumpInput = { + file: parsedFlags.file, + language: parsedFlags.language, + format: parsedFlags.format as 'json' | 'xml', + 'output-file': parsedFlags['output-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..505d7deaf --- /dev/null +++ b/src/lib/actions/AstDumpAction.ts @@ -0,0 +1,235 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import {PmdEngine} from '@salesforce/code-analyzer-pmd-engine'; +import type {PmdAstDumpResults, GenerateAstOptions} from '@salesforce/code-analyzer-pmd-engine'; +import {BundleName, getMessage} from '../messages.js'; + +export type AstDumpInput = { + file: string; + language?: string; + format: 'json' | 'xml'; + 'output-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; + + +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 { + 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])}; + } + + const pmdConfig = this.getDefaultPmdConfig(); + + // 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 getDefaultPmdConfig(): Record { + return { + java_command: 'java', + java_classpath_entries: [], + 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(): AstDumpAction { + return new AstDumpAction(); + } +} 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', 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..13936e774 --- /dev/null +++ b/test/commands/code-analyzer/ast-dump.test.ts @@ -0,0 +1,200 @@ +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 receivedActionInput!: AstDumpInput; + + beforeEach(() => { + executeSpy = vi.spyOn(AstDumpAction.prototype, 'execute').mockImplementation((input) => { + receivedActionInput = input; + return Promise.resolve({status: 'success', file: input.file, language: 'apex', ast: ''}); + }); + 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: ''}); + }); + 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: AstDumpInput) => { + 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: AstDumpInput) => { + 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: AstDumpInput) => { + 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..b7ff8ad7a --- /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, 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); + }); + }); +});