prettier-plugin-apex
Version:
Salesforce Apex plugin for Prettier
592 lines (591 loc) • 27.1 kB
JavaScript
/* eslint no-param-reassign: 0 */
import childProcess from "node:child_process";
import path from "node:path";
import process from "node:process";
import prettier from "prettier";
import { ALLOW_TRAILING_EMPTY_LINE, APEX_TYPES, TRAILING_EMPTY_LINE_AFTER_LAST_NODE, } from "./constants.js";
import { findNextUncommentedCharacter, getNativeExecutableWithFallback, getParentType, getSerializerBinDirectory, } from "./util.js";
const { getNextNonSpaceNonCommentCharacterIndex } = prettier.util;
async function parseTextWithSpawn(executable, text, anonymous) {
const args = [];
if (anonymous) {
args.push("-a");
}
return new Promise((resolve, reject) => {
const spawnedProcess = childProcess.spawn(executable, args, {
shell: true,
env: {
...process.env,
// #1513 - Gradle's generated Windows application wrapper checks for
// the DEBUG environment variable and will output verbose logs if it is set,
// which will break the parser output.
DEBUG: "",
},
});
spawnedProcess.stdin.write(text);
spawnedProcess.stdin.end();
let stdout = "";
let stderr = "";
spawnedProcess.stdout.on("data", (chunk) => {
stdout += chunk;
});
spawnedProcess.stderr.on("data", (chunk) => {
stderr += chunk;
});
spawnedProcess.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
}
else {
reject(new Error(stdout + stderr));
}
});
spawnedProcess.on("error", () => {
reject(new Error(stdout + stderr));
});
});
}
async function parseTextWithHttp(text, serverHost, serverPort, serverProtocol, anonymous) {
try {
const result = await fetch(`${serverProtocol}://${serverHost}:${serverPort}/api/ast`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
sourceCode: text,
anonymous,
prettyPrint: false,
}),
});
return await result.text();
}
catch (err) {
throw new Error(`Failed to connect to Apex parsing server\r\n${err.toString()}`);
}
}
// jorje calls the location node differently for different types of nodes,
// so we use this method to abstract away that difference
function getNodeLocation(node) {
if (node.loc) {
return node.loc;
}
if (node.location) {
return node.location;
}
return null;
}
function handleNodeSurroundedByCharacters(startCharacter, endCharacter) {
return (location, sourceCode, commentNodes) => ({
startIndex: findNextUncommentedCharacter(sourceCode, startCharacter, location.startIndex, commentNodes,
/* backwards */ true),
endIndex: findNextUncommentedCharacter(sourceCode, endCharacter, location.endIndex, commentNodes,
/* backwards */ false) + 1,
});
}
function handleNodeStartedWithCharacter(startCharacter) {
return (location, sourceCode, commentNodes) => ({
startIndex: findNextUncommentedCharacter(sourceCode, startCharacter, location.startIndex, commentNodes,
/* backwards */ true),
endIndex: location.endIndex,
});
}
function handleNodeEndedWithCharacter(endCharacter) {
return (location, sourceCode, commentNodes) => ({
startIndex: location.startIndex,
endIndex: findNextUncommentedCharacter(sourceCode, endCharacter, location.endIndex, commentNodes,
/* backwards */ false) + 1,
});
}
function handleAnonymousUnitLocation(_location, sourceCode) {
return {
startIndex: 0,
endIndex: sourceCode.length,
};
}
function handleMethodDeclarationLocation(location, sourceCode, commentNodes, node) {
// This is a method declaration with a body, so we can safely use the identity
// location.
if (node.stmnt.value) {
return location;
}
// This is a Method Declaration with no body, in which case we need to use the
// position of the closing parenthesis for the input parameters, e.g:
// void method();
return handleNodeEndedWithCharacter(")")(location, sourceCode, commentNodes);
}
function handleAnnotationLocation(location, sourceCode, commentNodes, node) {
// This is an annotation without parameters, so we only need to worry about
// the starting character
if (!node.parameters || node.parameters.length === 0) {
return handleNodeStartedWithCharacter("@")(location, sourceCode, commentNodes);
}
// If not, we need to use the position of the closing parenthesis after the
// parameters as well
return handleNodeSurroundedByCharacters("@", ")")(location, sourceCode, commentNodes);
}
function handleLimitValueLocation(location, sourceCode, commentNodes, node) {
// #1891 - the LIMIT node returned by jorje always gives us the location of
// the world LIMIT itself (i.e. 5 character long), but that leads to wrong
// format if the LIMIT (or the surrounding QUERY) is prettier ignored.
// Because of that, we will need to generate the location of the LIMIT value
// manually.
const valueString = node.i.toString();
return {
startIndex: location.startIndex,
endIndex: findNextUncommentedCharacter(sourceCode, node.i.toString(), location.endIndex, commentNodes,
/* backwards */ false) + valueString.length,
};
}
const identityFunction = (location) => location;
// Sometimes we need to delete a location node. For example, a WhereCompoundOp
// location does not make sense since it can appear in multiple places:
// SELECT Id FROM Account
// WHERE Name = 'Name'
// AND Name = 'Other Name' // <- this AND node here
// AND Name = 'Yet Another Name' <- this AND node here
// If we keep those locations, a comment might be duplicated since it is
// attached to one WhereCompoundOp, and that operator is printed multiple times.
const removeFunction = () => null;
function handleWhereCompoundExpressionLocation(location, sourceCode, commentNodes) {
// #1891 - the WHERE COMPOUND node returned by jorje doesn't give us the
// location of the full node, so we have to construct it manually based on
// the locations of its children. This works fine when the compound does not
// include opening and closing parenthesis, but when it does, we need to
// make sure that we take those into account. Otherwise, when the node is
// prettier ignored, we will end up not printing the correct parenthesis pair.
const previousParenthesisCharacterIndex = findNextUncommentedCharacter(sourceCode, "(", location.startIndex, commentNodes,
/* backwards */ true);
// There's no utility from Prettier that looks backwards to find the last
// non-commented, non-spaced character, so we have to use this workaround
// to check that the previous opening parenthesis applies to the current node.
const nextCharacterAfterParenthesisIndex = getNextNonSpaceNonCommentCharacterIndex(sourceCode, previousParenthesisCharacterIndex + 1);
if (nextCharacterAfterParenthesisIndex === location.startIndex) {
return handleNodeSurroundedByCharacters("(", ")")(location, sourceCode, commentNodes);
}
return identityFunction(location);
}
function handleWhereOperationExpressionLocation(location, sourceCode, commentNodes) {
// #1891 - jorje does not give us the full location of this node, so we have
// to build it manually. There are 2 cases:
// 1. The node is not surrounded by parenthesis, in which case we can use the
// identity function, e.g.:
// Id = '123
// 2. The node is surrounded by parenthesis, in which case we need to use the
// position of the parenthesis to build the location, e.g.:
// (Id = '123')
// It is important to make this distinction, because the WHERE COMPOUND
// algorithm above this relies on correct location from this node to build up
// the correct location for the WHERE COMPOUND node.
// If not handled correctly, ignored code can lead to invalid Apex.
const previousParenthesisCharacterIndex = findNextUncommentedCharacter(sourceCode, "(", location.startIndex, commentNodes,
/* backwards */ true);
// There's no utility from Prettier that looks backwards to find the last
// non-commented, non-spaced character, so we have to use this workaround
// to check that the previous opening parenthesis applies to the current node.
const nextCharacterAfterParenthesisIndex = getNextNonSpaceNonCommentCharacterIndex(sourceCode, previousParenthesisCharacterIndex + 1);
const nextCharacter = getNextNonSpaceNonCommentCharacterIndex(sourceCode, location.endIndex);
if (nextCharacterAfterParenthesisIndex === location.startIndex &&
nextCharacter &&
sourceCode[nextCharacter] === ")") {
return handleNodeSurroundedByCharacters("(", ")")(location, sourceCode, commentNodes);
}
return identityFunction(location);
}
function handleWhereUnaryExpressionLocation(location, sourceCode, commentNodes) {
// #1891 - jorje does not give us the full location of this node, so we have
// to build it manually. There are 2 cases:
// 1. The node is not surrounded by parenthesis, in which case we can use the
// identity function, e.g.:
// NOT Id = '123
// 2. The node is surrounded by parenthesis, in which case we need to use the
// position of the parenthesis to build the location, e.g.:
// (NOT Id = '123')
// It is important to make this distinction, because the WHERE COMPOUND
// algorithm above this relies on correct location from this node to build up
// the correct location for the WHERE COMPOUND node.
const previousParenthesisCharacterIndex = findNextUncommentedCharacter(sourceCode, "(", location.startIndex, commentNodes,
/* backwards */ true);
const nextCharacterAfterParenthesisIndex = getNextNonSpaceNonCommentCharacterIndex(sourceCode, previousParenthesisCharacterIndex + 1);
const nextCharacter = getNextNonSpaceNonCommentCharacterIndex(sourceCode, location.endIndex);
if (nextCharacterAfterParenthesisIndex === location.startIndex &&
nextCharacter &&
sourceCode[nextCharacter] === ")") {
return handleNodeSurroundedByCharacters("(", ")")(location, sourceCode, commentNodes);
}
return identityFunction(location);
}
// We need to generate the location for a node differently based on the node
// type. This object holds a String => Function mapping in order to do that.
const locationGenerationHandler = {
[APEX_TYPES.QUERY]: identityFunction,
[APEX_TYPES.SEARCH]: identityFunction,
[APEX_TYPES.FOR_INIT]: identityFunction,
[APEX_TYPES.FOR_ENHANCED_CONTROL]: identityFunction,
[APEX_TYPES.TERNARY_EXPRESSION]: identityFunction,
[APEX_TYPES.VARIABLE_EXPRESSION]: identityFunction,
[APEX_TYPES.INNER_CLASS_MEMBER]: identityFunction,
[APEX_TYPES.INNER_INTERFACE_MEMBER]: identityFunction,
[APEX_TYPES.INNER_ENUM_MEMBER]: identityFunction,
[APEX_TYPES.METHOD_MEMBER]: identityFunction,
[APEX_TYPES.IF_ELSE_BLOCK]: identityFunction,
[APEX_TYPES.NAME_VALUE_PARAMETER]: identityFunction,
[APEX_TYPES.VARIABLE_DECLARATION]: identityFunction,
[APEX_TYPES.BINARY_EXPRESSION]: identityFunction,
[APEX_TYPES.BOOLEAN_EXPRESSION]: identityFunction,
[APEX_TYPES.ASSIGNMENT_EXPRESSION]: identityFunction,
[APEX_TYPES.FIELD_MEMBER]: identityFunction,
[APEX_TYPES.VALUE_WHEN]: identityFunction,
[APEX_TYPES.ELSE_WHEN]: identityFunction,
[APEX_TYPES.WHERE_COMPOUND_OPERATOR]: removeFunction,
[APEX_TYPES.VARIABLE_DECLARATION_STATEMENT]: identityFunction,
[APEX_TYPES.WHERE_COMPOUND_EXPRESSION]: handleWhereCompoundExpressionLocation,
[APEX_TYPES.WHERE_OPERATION_EXPRESSION]: handleWhereOperationExpressionLocation,
[APEX_TYPES.WHERE_UNARY_EXPRESSION]: handleWhereUnaryExpressionLocation,
[APEX_TYPES.SELECT_INNER_QUERY]: handleNodeSurroundedByCharacters("(", ")"),
[APEX_TYPES.ANONYMOUS_BLOCK_UNIT]: handleAnonymousUnitLocation,
[APEX_TYPES.NESTED_EXPRESSION]: handleNodeSurroundedByCharacters("(", ")"),
[APEX_TYPES.PROPERTY_MEMBER]: handleNodeEndedWithCharacter("}"),
[APEX_TYPES.SWITCH_STATEMENT]: handleNodeEndedWithCharacter("}"),
[APEX_TYPES.NEW_LIST_LITERAL]: handleNodeEndedWithCharacter("}"),
[APEX_TYPES.NEW_SET_LITERAL]: handleNodeEndedWithCharacter("}"),
[APEX_TYPES.NEW_MAP_LITERAL]: handleNodeEndedWithCharacter("}"),
[APEX_TYPES.NEW_STANDARD]: handleNodeEndedWithCharacter(")"),
[APEX_TYPES.VARIABLE_DECLARATIONS]: handleNodeEndedWithCharacter(";"),
[APEX_TYPES.NEW_KEY_VALUE]: handleNodeEndedWithCharacter(")"),
[APEX_TYPES.METHOD_CALL_EXPRESSION]: handleNodeEndedWithCharacter(")"),
[APEX_TYPES.ANNOTATION]: handleAnnotationLocation,
[APEX_TYPES.METHOD_DECLARATION]: handleMethodDeclarationLocation,
[APEX_TYPES.LIMIT_VALUE]: handleLimitValueLocation,
};
/*
* Generic Depth-First Search algorithm that applies a list of functions to each
* node in the tree.
* Each function can hook into various parts of the DFS process:
* - gatherChildrenContext: gathering contexts for children nodes. When the
* children nodes are visited, they will be passed this context.
* - accumulator: accumulating results from children nodes. This is run after
* every individual child node is visited.
* - apply: applying the function to the current node. This is run after all
* children nodes have been visited.
*/
function dfsPostOrderApply(node, fns, currentContexts) {
const finalChildrenResults = new Array(fns.length);
const childrenContexts = new Array(fns.length);
for (let i = 0; i < fns.length; i++) {
childrenContexts[i] = fns[i]?.gatherChildrenContext?.(node, currentContexts ? currentContexts[i] : undefined);
}
const keys = Object.keys(node);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (typeof node[key] === "object") {
const childrenResults = dfsPostOrderApply(node[key], fns, childrenContexts);
for (let j = 0; j < fns.length; j++) {
finalChildrenResults[j] = fns[j]?.accumulator?.(childrenResults[j], finalChildrenResults[j]);
}
}
}
const results = [];
for (let i = 0; i < fns.length; i++) {
results.push(fns[i]?.apply(node, finalChildrenResults[i], currentContexts ? currentContexts[i] : undefined, childrenContexts[i]));
}
return results;
}
/**
* Generate and/or fix node locations, because jorje sometimes either provides
* wrong location information or a node, or doesn't provide any information at
* all.
* We will fix it here by enforcing that a parent node start
* index is always <= any child node start index, and a parent node end index
* is always >= any child node end index.
*/
const nodeLocationVisitor = (sourceCode, commentNodes) => ({
accumulator: (entry, accumulated) => {
if (!accumulated) {
return entry;
}
if (!entry) {
return accumulated;
}
if (accumulated.startIndex > entry.startIndex) {
accumulated.startIndex = entry.startIndex;
}
if (accumulated.endIndex < entry.endIndex) {
accumulated.endIndex = entry.endIndex;
}
return accumulated;
},
apply: (node, currentLocation) => {
const apexClass = node["@class"];
let handlerFn;
if (apexClass) {
handlerFn = locationGenerationHandler[apexClass];
if (!handlerFn) {
const parentClass = getParentType(apexClass);
if (parentClass) {
handlerFn = locationGenerationHandler[parentClass];
}
}
}
if (handlerFn && currentLocation) {
node.loc = handlerFn(currentLocation, sourceCode, commentNodes, node);
}
else if (handlerFn && node.loc) {
node.loc = handlerFn(node.loc, sourceCode, commentNodes, node);
}
const nodeLoc = node.loc;
if (!nodeLoc) {
delete node.loc;
}
else if (nodeLoc && currentLocation) {
if (nodeLoc.startIndex > currentLocation.startIndex) {
nodeLoc.startIndex = currentLocation.startIndex;
}
else {
currentLocation.startIndex = nodeLoc.startIndex;
}
if (nodeLoc.endIndex < currentLocation.endIndex) {
nodeLoc.endIndex = currentLocation.endIndex;
}
else {
currentLocation.endIndex = nodeLoc.endIndex;
}
}
if (currentLocation) {
return { ...currentLocation };
}
if (nodeLoc) {
return {
startIndex: nodeLoc.startIndex,
endIndex: nodeLoc.endIndex,
};
}
return null;
},
});
const metadataVisitor = (emptyLineLocations) => ({
apply: (node, _accumulated, context, childrenContext) => {
const apexClass = node["@class"];
// #511 - If the user manually specify linebreaks in their original query,
// we will use that as a heuristic to manually add hardlines to the result
// query as well.
if (apexClass === APEX_TYPES.SEARCH || apexClass === APEX_TYPES.QUERY) {
node.forcedHardline = node.loc.startLine !== node.loc.endLine;
}
// jorje parses all `if` and `else if` blocks into `ifBlocks`, so we add
// `ifBlockIndex` into the node for handling code to differentiate them.
else if (apexClass === APEX_TYPES.IF_ELSE_BLOCK) {
node.ifBlocks.forEach((ifBlock, index) => {
ifBlock.ifBlockIndex = index;
});
}
if ("inputParameters" in node && Array.isArray(node.inputParameters)) {
node.inputParameters.forEach((inputParameter) => {
inputParameter.insideParenthesis = true;
});
}
const trailingEmptyLineAllowed = ALLOW_TRAILING_EMPTY_LINE.includes(apexClass);
const nodeLoc = getNodeLocation(node);
let isLastNodeInArray = false;
// Here we flag the current node as the last node in the array, because
// we don't want a trailing empty line after it.
if (context?.arraySiblings) {
isLastNodeInArray =
context.arraySiblings.indexOf(node) ===
context.arraySiblings.length - 1;
}
// Here we turn off trailing empty line for a child node when its next
// sibling is on the same line.
// The reasoning is that for a block of code like this:
// ```
// Integer a = 1; Integer c = 2; Integer c = 3;
//
// Integer d = 4;
// ```
// We don't want a trailing empty line after `Integer a = 1;`
// so we need to mark it as a special node.
// We are doing this at the parent node level, because when we run the
// Depth-First search, we don't have enough context at the child node level
// to determine if its next sibling is on the same line or not.
if (childrenContext.arraySiblings) {
for (let i = 0; i < childrenContext.arraySiblings.length; i++) {
const currentChild = childrenContext.arraySiblings[i];
const nextChildIndex = i + 1;
if (nextChildIndex < childrenContext.arraySiblings.length) {
const nextChild = childrenContext.arraySiblings[nextChildIndex];
if (currentChild.trailingEmptyLine &&
currentChild.loc &&
nextChild.loc &&
currentChild.loc.endLine === nextChild.loc.startLine) {
currentChild.trailingEmptyLine = false;
}
}
}
}
if (apexClass &&
nodeLoc &&
context.allowTrailingEmptyLine &&
trailingEmptyLineAllowed &&
!isLastNodeInArray) {
const nextLine = nodeLoc.endLine + 1;
const nextEmptyLine = emptyLineLocations.indexOf(nextLine);
if (nextEmptyLine !== -1) {
node.trailingEmptyLine = true;
}
}
},
gatherChildrenContext: (node, currentContext) => {
const apexClass = node["@class"];
let allowTrailingEmptyLineWithin;
const isSpecialClass = TRAILING_EMPTY_LINE_AFTER_LAST_NODE.includes(apexClass);
const trailingEmptyLineAllowed = ALLOW_TRAILING_EMPTY_LINE.includes(apexClass);
if (isSpecialClass) {
allowTrailingEmptyLineWithin = false;
}
else if (trailingEmptyLineAllowed) {
allowTrailingEmptyLineWithin = true;
}
else {
// currentContext is undefined for the root node, we hardcode
// allowTrailingEmptyLine to true for it
allowTrailingEmptyLineWithin =
currentContext?.allowTrailingEmptyLine ?? true;
}
let arraySiblings;
if (Array.isArray(node) && node.length > 0) {
arraySiblings = node;
}
return {
allowTrailingEmptyLine: allowTrailingEmptyLineWithin,
arraySiblings,
};
},
});
function getLineNumber(lineIndexes, charIndex) {
let low = 0;
let high = lineIndexes.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midIndex = lineIndexes[mid] ?? 0;
const beforeMidIndex = lineIndexes[mid - 1] ?? 0;
if (midIndex >= charIndex && beforeMidIndex < charIndex) {
return mid;
}
if (midIndex < charIndex) {
low = mid + 1;
}
else {
high = mid - 1;
}
}
return -1;
}
// For each node, the jorje compiler gives us its line and its index within
// that line; however we use this method to resolve that line index to a global
// index of that node within the source code. That allows us to use prettier
// utility methods.
const lineIndexVisitor = (lineIndexes) => ({
apply: (node) => {
const nodeLoc = getNodeLocation(node);
if (nodeLoc && !("startLine" in nodeLoc)) {
// The location node that we manually generate do not contain startLine
// information, so we will create them here.
nodeLoc.startLine =
nodeLoc.line ?? getLineNumber(lineIndexes, nodeLoc.startIndex);
}
if (nodeLoc && !("endLine" in nodeLoc)) {
nodeLoc.endLine = getLineNumber(lineIndexes, nodeLoc.endIndex);
// Edge case: root node
if (nodeLoc.endLine < 0) {
nodeLoc.endLine = lineIndexes.length - 1;
}
}
if (nodeLoc && !("column" in nodeLoc)) {
const nodeStartLineIndex = lineIndexes[nodeLoc.startLine ?? getLineNumber(lineIndexes, nodeLoc.startIndex)];
if (nodeStartLineIndex !== undefined) {
nodeLoc.column = nodeLoc.startIndex - nodeStartLineIndex;
}
}
},
});
// Get a map of line number to the index of its first character
function getLineIndexes(sourceCode) {
// First line always start with index 0
const lineIndexes = [0];
let characterIndex = 0;
let lineIndex = 1;
while (characterIndex < sourceCode.length) {
const eolIndex = sourceCode.indexOf("\n", characterIndex);
if (eolIndex < 0) {
break;
}
const lastLineIndex = lineIndexes[lineIndex - 1];
/* v8 ignore next 3 */
if (lastLineIndex === undefined) {
return lineIndexes;
}
lineIndexes[lineIndex] = lastLineIndex + (eolIndex - characterIndex) + 1;
characterIndex = eolIndex + 1;
lineIndex += 1;
}
lineIndexes[lineIndex] = sourceCode.length;
return lineIndexes;
}
function getEmptyLineLocations(sourceCode) {
const whiteSpaceRegEx = /^\s*$/;
const lines = sourceCode.split("\n");
return lines
.map((line) => whiteSpaceRegEx.test(line))
.reduce((accumulator, currentValue, currentIndex) => {
if (currentValue) {
accumulator.push(currentIndex + 1);
}
return accumulator;
}, []);
}
export default async function parse(sourceCode, options) {
let serializedAst;
let stderr = "";
if (options.apexStandaloneParser === "built-in") {
serializedAst = await parseTextWithHttp(sourceCode, options.apexStandaloneHost, options.apexStandalonePort, options.apexStandaloneProtocol, options.parser === "apex-anonymous");
}
else if (options.apexStandaloneParser === "native") {
const serializerBin = await getNativeExecutableWithFallback();
const result = await parseTextWithSpawn(serializerBin, sourceCode, options.parser === "apex-anonymous");
serializedAst = result.stdout;
stderr = result.stderr;
}
else {
const result = await parseTextWithSpawn(path.join(await getSerializerBinDirectory(), `apex-ast-serializer${process.platform === "win32" ? ".bat" : ""}`), sourceCode, options.parser === "apex-anonymous");
serializedAst = result.stdout;
stderr = result.stderr;
}
if (serializedAst) {
const ast = JSON.parse(serializedAst);
if (ast[APEX_TYPES.PARSER_OUTPUT] &&
ast[APEX_TYPES.PARSER_OUTPUT].parseErrors.length > 0) {
const errors = ast[APEX_TYPES.PARSER_OUTPUT].parseErrors.map((err) => `${err.message}.`);
throw new Error(errors.join("\r\n"));
}
ast.comments = ast[APEX_TYPES.PARSER_OUTPUT].hiddenTokenMap
.map((item) => item[1])
.filter((node) => node["@class"] === APEX_TYPES.BLOCK_COMMENT ||
node["@class"] === APEX_TYPES.INLINE_COMMENT);
const lastComment = ast.comments.at(-1);
if (lastComment) {
const nextCharAfterLastCommentIndex = getNextNonSpaceNonCommentCharacterIndex(sourceCode, lastComment.location.endIndex);
if (nextCharAfterLastCommentIndex === sourceCode.length) {
// #1777 - We don't want a trailing empty line after the last comment in
// the document, because that will be a duplicate of the final empty line.
lastComment.trailingEmptyLine = false;
}
}
dfsPostOrderApply(ast, [
nodeLocationVisitor(sourceCode, ast.comments),
lineIndexVisitor(getLineIndexes(sourceCode)),
metadataVisitor(getEmptyLineLocations(sourceCode)),
]);
return ast;
}
throw new Error(`Failed to parse Apex code: ${stderr}`);
}