Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions messages/ast-dump-command.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ export const CliTelemetryEvents = {
export const CliCommands = {
RUN: 'run',
RULES: 'rules',
CONFIG: 'config'
CONFIG: 'config',
AST_DUMP: 'ast-dump'
}
66 changes: 66 additions & 0 deletions src/commands/code-analyzer/ast-dump.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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']}`);
}
}
}
235 changes: 235 additions & 0 deletions src/lib/actions/AstDumpAction.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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<string, string> = {
'.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<AstDumpOutput> {
// 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<typeof PmdEngine>[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<string, unknown> {
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<string, string> {
const attrs: Record<string, string> = {};
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();
}
}
1 change: 1 addition & 0 deletions src/lib/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading