UNPKG

prettier-plugin-apex

Version:

Salesforce Apex plugin for Prettier

251 lines (250 loc) 9.84 kB
/* eslint no-param-reassign: 0 */ import fs from "node:fs/promises"; import module from "node:module"; import nodePath from "node:path"; import * as url from "node:url"; import { APEX_TYPES, DATA_CATEGORY, MODIFIER, ORDER, ORDER_NULL, QUERY, QUERY_WHERE, } from "./constants.js"; export function isBinaryish(node) { return (node["@class"] === APEX_TYPES.BOOLEAN_EXPRESSION || node["@class"] === APEX_TYPES.BINARY_EXPRESSION); } /** * Check if this comment is an ApexDoc-style comment. * This code is straight from prettier JSDoc detection. * @param comment the comment to check. */ export function isApexDocComment(comment) { const lines = comment.value.split("\n"); return (lines.length > 1 && lines .slice(1, lines.length - 1) .every((commentLine) => commentLine.trim()[0] === "*")); } export function checkIfParentIsDottedExpression(path) { const node = path.getNode(); const parentNode = path.getParentNode(); let result = false; // We're making an assumption here that `callParent` is always synchronous. // We're doing it because FastPath does not expose other ways to find the // parent name. let parentNodeName; let grandParentNodeName; path.callParent((innerPath) => { parentNodeName = innerPath.getName(); }); path.callParent((innerPath) => { grandParentNodeName = innerPath.getName(); }, 1); if (parentNodeName === "dottedExpr") { result = true; } else if (node["@class"] === APEX_TYPES.VARIABLE_EXPRESSION && parentNode["@class"] === APEX_TYPES.ARRAY_EXPRESSION && grandParentNodeName === "dottedExpr") { // a // .b[0] // <- Node b here // .c() // For this situation we want to flag b as a nested dotted expression, // so that we can make it part of the grand parent's group, even though // technically it's the grandchild of the dotted expression. result = true; } return result; } // The metadata corresponding to these keys cannot be compared for some reason // or another, so we will delete them before the AST comparison const METADATA_TO_IGNORE = [ "loc", "location", "lastNodeLoc", "text", "rawQuery", "@id", // It is impossible to preserve the comment AST. Neither recast nor // prettier tries to do it so we are not going to bother either. "comments", "$", "leading", "trailing", "hiddenTokenMap", "trailingEmptyLine", "forcedHardline", ]; /** * Massaging the AST node so that it can be compared. This gets called by * Prettier's internal code * @param ast the Abstract Syntax Tree to compare * @param newObj the newly created object */ export function massageAstNode(ast, newObj) { // Handling ApexDoc if (ast["@class"] && ast["@class"] === APEX_TYPES.BLOCK_COMMENT && isApexDocComment(ast)) { newObj.value = ast.value.replace(/\s/g, ""); } if (ast.scope && typeof ast.scope === "string") { // Apex is case insensitivity, but in some case we're forcing the strings // to be uppercase for consistency so the ASTs may be different between // the original and parsed strings. newObj.scope = ast.scope.toUpperCase(); } else if (ast.dottedExpr && ast.dottedExpr.value && ast.dottedExpr.value.names && ast.dottedExpr.value["@class"] === APEX_TYPES.VARIABLE_EXPRESSION && ast.names) { // This is a workaround for #38 - jorje sometimes groups names with // spaces as dottedExpr, so we can't compare AST effectively. // In those cases we will bring the dottedExpr out into the names. newObj.names = newObj.dottedExpr.value.names.concat(newObj.names); newObj.dottedExpr = newObj.dottedExpr.value.dottedExpr; } else if (ast["@class"] && ast["@class"] === APEX_TYPES.WHERE_COMPOUND_EXPRESSION) { // This flattens the SOQL/SOSL Compound Expression, e.g.: // SELECT Id FROM Account WHERE Name = 'Name' AND (Status = 'Active' AND City = 'Boston') // is equivalent to: // SELECT Id FROM Account WHERE Name = 'Name' AND Status = 'Active' AND City = 'Boston' for (let i = newObj.expr.length - 1; i >= 0; i -= 1) { if (newObj.expr[i]["@class"] === APEX_TYPES.WHERE_COMPOUND_EXPRESSION && newObj.expr[i].op["@class"] === newObj.op["@class"]) { newObj.expr.splice(i, 1, ...newObj.expr[i].expr); } } } METADATA_TO_IGNORE.forEach((name) => delete newObj[name]); } /** * Helper function to find a character in a string, starting at an index. * It will ignore characters that are part of comments. */ export function findNextUncommentedCharacter(sourceCode, character, fromIndex, commentNodes, backwards = false) { let indexFound = false; let index = -1; const findIndex = (comment) => comment.location && comment.location.startIndex && comment.location.endIndex && comment.location.startIndex <= index && comment.location.endIndex - 1 >= index; while (!indexFound) { if (backwards) { index = sourceCode.lastIndexOf(character, fromIndex); } else { index = sourceCode.indexOf(character, fromIndex); } indexFound = commentNodes.filter(findIndex).length === 0; if (backwards) { fromIndex = index - 1; } else { fromIndex = index + 1; } } return index; } // Optimization to look up parent types faster const PARENT_TYPES = [ ...Object.values(APEX_TYPES), ...Object.keys(DATA_CATEGORY), ...Object.keys(MODIFIER), ...Object.keys(QUERY), ...Object.keys(QUERY_WHERE), ...Object.keys(ORDER), ...Object.keys(ORDER_NULL), ] .filter((type) => type.includes("$")) .reduce((acc, type) => { const [parentType] = type.split("$"); if (parentType) { acc[type] = parentType; } return acc; }, {}); export function getParentType(type) { return PARENT_TYPES[type]; } // One big difference between our precedence list vs Prettier's core // is that == (and its precedence equivalences) has the same precedence // as < (and its precedence equivalences). // e.g. a > b == c > d: // in Javascript, this would be parsed this as: left (a > b), op (==), right (c > d) // instead, jorje parses this as: // left (a > b == c), op (>), right (d) // The consequence is that formatted code does not look as nice as Prettier's core, // but we can't change it because it will change the code's behavior. const PRECEDENCE = {}; [ ["||"], ["&&"], ["|"], ["^"], ["&"], ["==", "===", "!=", "!==", "<>", "<", ">", "<=", ">="], [">>", "<<", ">>>"], ["+", "-"], ["*", "/", "%"], ].forEach((tier, i) => { tier.forEach((op) => { PRECEDENCE[op] = i; }); }); export function getPrecedence(op) { const precedence = PRECEDENCE[op]; /* v8 ignore start */ if (precedence === undefined) { throw new Error(`Failed to get precedence for operator ${op}`); } /* v8 ignore start */ return precedence; } export async function doesFileExist(file) { return fs .access(file, fs.constants.F_OK) .then(() => true) .catch(() => false); } // The relative path to the binary can be different based on how the script // is being run - running using tsx vs running after code has been compiled // to `dist` directory. We use this method to abstract out that difference. export async function getSerializerBinDirectory() { let serializerBin = nodePath.join(url.fileURLToPath(new URL(".", import.meta.url)), "../vendor/apex-ast-serializer/bin"); /* v8 ignore start */ if (!(await doesFileExist(serializerBin))) { serializerBin = nodePath.join(url.fileURLToPath(new URL(".", import.meta.url)), "../../vendor/apex-ast-serializer/bin"); } // NodeJS struggles to spawn Windows processes with path having special characters, // like `=`, so we try to minimize that by using relative paths. /* v8 ignore stop */ return nodePath.relative(process.cwd(), serializerBin); } export const NATIVE_PACKAGES = { "darwin-x64": "@prettier-apex/apex-ast-serializer-darwin-x64", "darwin-arm64": "@prettier-apex/apex-ast-serializer-darwin-arm64", "linux-x64": "@prettier-apex/apex-ast-serializer-linux-x64", "win32-x64": "@prettier-apex/apex-ast-serializer-win32-x64", }; export function getNativeExecutableNameForPlatform(fullPlatform) { return `apex-ast-serializer-${fullPlatform}${fullPlatform.startsWith("win32") ? ".exe" : ""}`; } export async function getNativeExecutableWithFallback() { const { arch, platform } = process; const packageName = NATIVE_PACKAGES[`${platform}-${arch}`]; try { if (!packageName) { throw new Error("No prebuilt binary available for this platform"); } const nativeBin = nodePath.join(packageName, getNativeExecutableNameForPlatform(`${platform}-${arch}`)); const require = module.createRequire(import.meta.url); return nodePath.relative(process.cwd(), require.resolve(nativeBin)); } catch (e) { if ("code" in e && e.code === "MODULE_NOT_FOUND") { console.warn(`Your platform ${platform}-${arch} is natively supported by Prettier Apex, but the executable cannot be found.`); console.warn(`If you didn't intentionally install Prettier Apex with ignore-optional flag, please file a bug report.`); console.warn(`Falling back to Java-based serializer.`); } return nodePath.join(await getSerializerBinDirectory(), `apex-ast-serializer${process.platform === "win32" ? ".bat" : ""}`); } }