/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {JS} from '../tree';
import {J, Statement} from '../../java';
import {Cursor, Tree} from '../../tree';
import {TreePrinters} from '../../print';
import {JavaScriptParser} from '../parser';
import {WhitespaceReconciler} from './whitespace-reconciler';
import {randomId} from '../../uuid';
import {PrettierStyle, StyleKind} from '../style';
import {NamedStyles} from '../../style';
import {emptyMarkers, findMarker} from '../../markers';
import {NormalizeWhitespaceVisitor} from './normalize-whitespace-visitor';
import {MinimumViableSpacingVisitor} from './minimum-viable-spacing-visitor';
import {loadPrettierVersion} from './prettier-config-loader';
import {updateIfChanged} from "../../util";
/**
* Loads Prettier for formatting.
*
* We use the main Prettier module (not standalone) because:
* 1. It automatically handles parser resolution
* 2. Works better with CommonJS (avoids ESM issues in Jest)
* 3. Simpler - no need to manually load plugins
*/
async function loadPrettierFormatting(version?: string): Promise {
if (version) {
// Ensure the version is installed and get it from cache
return await loadPrettierVersion(version);
}
// Use bundled Prettier
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('prettier');
}
/**
* Options for Prettier formatting.
*/
export interface PrettierFormatOptions {
/**
* Tab width for indentation. Defaults to 2.
*/
tabWidth?: number;
/**
* Use tabs instead of spaces. Defaults to false.
*/
useTabs?: boolean;
/**
* Print semicolons at the ends of statements. Defaults to true.
*/
semi?: boolean;
/**
* Use single quotes instead of double quotes. Defaults to false.
*/
singleQuote?: boolean;
/**
* Print trailing commas wherever possible. Defaults to 'all'.
*/
trailingComma?: 'all' | 'es5' | 'none';
/**
* Print width for line wrapping. Defaults to 80.
*/
printWidth?: number;
/**
* Change when properties in objects are quoted.
* - "as-needed" - Only add quotes around object properties where required.
* - "consistent" - If at least one property in an object requires quotes, quote all properties.
* - "preserve" - Respect the input use of quotes in object properties.
* Defaults to "as-needed".
*/
quoteProps?: 'as-needed' | 'consistent' | 'preserve';
/**
* The Prettier version to use (e.g., "3.4.2").
* If specified, loads that version from cache or installs it.
* If not specified, uses the bundled Prettier.
*/
prettierVersion?: string;
}
/**
* Formats a JavaScript/TypeScript AST using Prettier.
*
* This function:
* 1. Prints the AST to a string
* 2. Formats the string using Prettier
* 3. Parses the formatted string back to an AST (without type attribution for performance)
* 4. Reconciles the whitespace from the formatted AST back into the original AST
*
* The result preserves the original AST's structure, types, and markers while
* applying Prettier's formatting rules for whitespace.
*
* @param sourceFile The source file to format
* @param options Prettier formatting options
* @param stopAfter Optional node to stop formatting after. Once this node is exited,
* no more whitespace changes are applied to subsequent nodes.
* @returns The formatted source file with reconciled whitespace
*/
export async function prettierFormat(
sourceFile: JS.CompilationUnit,
options: PrettierFormatOptions = {},
stopAfter?: J
): Promise {
// Load Prettier - either specific version or bundled
let prettier: typeof import('prettier');
try {
prettier = await loadPrettierFormatting(options.prettierVersion);
} catch (e) {
console.error('Failed to load Prettier:', e);
throw new Error(
`Prettier is not installed or failed to load. Please install it with: npm install prettier. Error: ${e}`
);
}
// Step 1: Print the AST to string
const originalSource = await TreePrinters.print(sourceFile);
// Step 2: Determine parser based on source path
const parser = getParserForPath(sourceFile.sourcePath);
// Step 3: Format with Prettier
// Using the main Prettier module - parsers are resolved automatically
// Only set parser and filepath - pass through all other options without defaults
// This lets Prettier use its own defaults for any unspecified options
const prettierOptions: Record = {
parser,
filepath: sourceFile.sourcePath, // Important: tells Prettier the file type for proper formatting
...options,
};
// Remove our internal option that Prettier doesn't understand
delete prettierOptions.prettierVersion;
const formattedSource = await prettier.format(originalSource, prettierOptions);
// Step 4: Parse the formatted string using parseOnly() for maximum performance
// (bypasses TypeScript's type checker entirely)
const formattedParser = new JavaScriptParser();
const formattedAst = await formattedParser.parseOnly({
sourcePath: sourceFile.sourcePath,
text: formattedSource
});
if (!formattedAst) {
console.warn('Prettier formatting: Failed to parse formatted output, returning original');
return sourceFile;
}
// Step 5: Reconcile whitespace from formatted AST to original AST
// Note: For subtree formatting with pruned trees, the structure may differ
// (e.g., Prettier removes empty placeholder statements). In such cases,
// we return the formatted AST directly and let the caller handle
// subtree-level reconciliation.
const reconciler = new WhitespaceReconciler();
const formattedCu = formattedAst as JS.CompilationUnit;
const result = reconciler.reconcile(sourceFile, formattedCu, undefined, stopAfter);
// If reconciliation succeeded, return the reconciled original with updated whitespace
// If it failed (structure mismatch), return the formatted AST for subtree reconciliation
return reconciler.isCompatible() ? result as JS.CompilationUnit : formattedCu;
}
/**
* Maps file extensions to Prettier parser names.
*/
const PRETTIER_PARSER_BY_EXTENSION: Record = {
'.ts': 'typescript',
'.tsx': 'typescript',
'.js': 'babel',
'.jsx': 'babel',
'.mjs': 'babel',
'.cjs': 'babel',
};
/**
* Determines the Prettier parser to use based on file extension.
*/
function getParserForPath(filePath: string): string {
const lower = filePath.toLowerCase();
for (const [ext, parser] of Object.entries(PRETTIER_PARSER_BY_EXTENSION)) {
if (lower.endsWith(ext)) {
return parser;
}
}
return 'babel'; // Default parser
}
/**
* Represents a segment of the path from root to a target node.
*/
interface PathSegment {
/** The property name containing the child */
property: string;
/** For array properties, the index of the element */
index?: number;
}
/**
* Result of extracting a path from cursor.
*/
interface PathExtractionResult {
/** The compilation unit (root of the tree) */
compilationUnit: JS.CompilationUnit | undefined;
/** The path from root to target */
path: PathSegment[];
}
/**
* Extracts the path from a CompilationUnit to a target node using the cursor.
* Returns the path segments in order from root to target.
*
* @param cursor The cursor, which may not include the target (e.g., when passing cursor.parent)
* @param target The target node we're looking for
*/
function extractPathFromCursor(cursor: Cursor, target: any): PathExtractionResult {
const pathNodes = cursor.asArray().reverse(); // root to target
const segments: PathSegment[] = [];
let compilationUnit: JS.CompilationUnit | undefined;
// Helper to check if two nodes are the same (by identity or ID)
const isSameNode = (a: any, b: any): boolean => {
if (a === b) return true;
if (a && b && typeof a === 'object' && typeof b === 'object' && 'id' in a && 'id' in b) {
return a.id === b.id;
}
return false;
};
// Helper to find a child in a parent and return the segment
const findChildInParent = (parent: any, child: any): PathSegment | undefined => {
if (!parent || typeof parent !== 'object') return undefined;
for (const key of Object.keys(parent)) {
const value = (parent as any)[key];
if (value == null) continue;
if (Array.isArray(value)) {
for (let idx = 0; idx < value.length; idx++) {
const item = value[idx];
if (isSameNode(item, child)) {
return { property: key, index: idx };
}
}
} else if (isSameNode(value, child)) {
return { property: key };
}
}
return undefined;
};
for (let i = 0; i < pathNodes.length - 1; i++) {
const parent = pathNodes[i];
const child = pathNodes[i + 1];
// Check if this node is the CompilationUnit
if (parent?.kind === JS.Kind.CompilationUnit) {
compilationUnit = parent as JS.CompilationUnit;
}
const segment = findChildInParent(parent, child);
if (segment) {
segments.push(segment);
}
}
// Check the last node for CompilationUnit
const lastNode = pathNodes[pathNodes.length - 1];
if (lastNode?.kind === JS.Kind.CompilationUnit) {
compilationUnit = lastNode as JS.CompilationUnit;
}
// If the cursor doesn't include the target, add the final segment
// This handles the case when autoFormat is called with cursor.parent
if (lastNode && !isSameNode(lastNode, target)) {
const finalSegment = findChildInParent(lastNode, target);
if (finalSegment) {
segments.push(finalSegment);
}
}
return { compilationUnit, path: segments };
}
/**
* Creates a "null" identifier placeholder for use in pruned trees.
* Using "null" instead of an empty statement ensures Prettier sees similar
* line lengths and doesn't collapse multi-line code to single-line.
*/
function createNullPlaceholder(prefix: J.Space): J.Identifier {
return {
kind: J.Kind.Identifier,
id: randomId(),
markers: emptyMarkers,
prefix: prefix,
annotations: [],
simpleName: "null",
type: undefined,
fieldType: undefined
};
}
/**
* Prunes a compilation unit for efficient Prettier formatting of a subtree,
* and substitutes the (potentially modified) target at the path location.
*
* For J.Block#statements along the path to the target:
* - Prior siblings are replaced with "null" identifier placeholders (to maintain line length)
* - Following siblings are omitted entirely
*
* This optimization reduces the amount of code Prettier needs to process
* while maintaining approximate line positions so Prettier doesn't collapse
* multi-line code.
*
* @param cu The compilation unit to prune
* @param path The path from root to the target subtree
* @param target The (potentially modified) target to substitute at the path location
* @returns A pruned copy of the compilation unit with the target substituted
*/
function pruneTreeForSubtree(cu: JS.CompilationUnit, path: PathSegment[], target: any): JS.CompilationUnit {
return pruneNode(cu, path, 0, target) as JS.CompilationUnit;
}
/**
* Recursively prunes a node, following the path, pruning J.Block#statements,
* and substituting the target at the final location.
*/
function pruneNode(node: any, path: PathSegment[], pathIndex: number, target: any): any {
if (pathIndex >= path.length) {
// Reached the target location - substitute with the (potentially modified) target
return target;
}
const segment = path[pathIndex];
const value = node[segment.property];
if (value == null) {
return node;
}
// Handle J.Block#statements specially - prune siblings
if (node.kind === J.Kind.Block && segment.property === 'statements' && segment.index !== undefined) {
const statements = value as J.RightPadded[];
const targetIndex = segment.index;
// Create pruned statements array:
// - Prior siblings: replace with "null" placeholders (to maintain line length)
// - Target: recurse into it (following the path through RightPadded.element)
// - Following siblings: omit entirely
const prunedStatements: J.RightPadded[] = [];
for (let i = 0; i <= targetIndex; i++) {
if (i < targetIndex) {
// Prior sibling - replace with "null" placeholder
// Preserve the original prefix to maintain line positions
const originalPrefix = statements[i].element.prefix;
const placeholder = createNullPlaceholder(originalPrefix);
prunedStatements.push({
kind: J.Kind.RightPadded,
element: placeholder,
after: statements[i].after,
markers: statements[i].markers
} as J.RightPadded);
} else {
// Target - recurse into the RightPadded (path will handle .element)
const prunedRightPadded = pruneNode(statements[i], path, pathIndex + 1, target);
prunedStatements.push(prunedRightPadded);
}
}
// Following siblings are omitted
return updateIfChanged(node, {statements: prunedStatements});
}
// For other properties, just recurse without pruning
if (Array.isArray(value) && segment.index !== undefined) {
const childNode = value[segment.index];
const prunedChild = pruneNode(childNode, path, pathIndex + 1, target);
if (prunedChild !== childNode) {
// Create a copy of the array with the updated element
const newArray = [...value];
newArray[segment.index] = prunedChild;
return { ...node, [segment.property]: newArray };
}
} else if (!Array.isArray(value)) {
const prunedChild = pruneNode(value, path, pathIndex + 1, target);
if (prunedChild !== value) {
return { ...node, [segment.property]: prunedChild };
}
}
return node;
}
/**
* Finds a node in a tree by following a path of segments.
* Used to locate the target node in the formatted tree.
*
* For block statements, the target is always at the last index since
* following siblings are omitted during pruning.
*/
function findByPath(tree: any, path: PathSegment[]): any {
let current = tree;
for (const segment of path) {
if (current == null) return undefined;
const value = current[segment.property];
if (value == null) return undefined;
if (Array.isArray(value) && segment.index !== undefined) {
// For block statements, target is always at the last index
// since following siblings are omitted during pruning
const isBlockStatements = current.kind === J.Kind.Block && segment.property === 'statements';
const index = isBlockStatements ? value.length - 1 : segment.index;
const item = value[index];
if (item == null) return undefined;
current = item;
} else {
current = value;
}
}
return current;
}
/**
* Formats a subtree of a JavaScript/TypeScript AST using Prettier.
*
* This function is optimized for formatting a small part of a larger tree:
* 1. Extracts the path from compilation unit to target
* 2. Prunes the tree (replaces siblings with placeholders)
* 3. Formats the pruned tree with Prettier
* 4. Finds the target in the formatted tree
* 5. Reconciles only the target subtree's whitespace
*
* @param target The subtree to format
* @param cursor The cursor pointing to or near the target
* @param options Prettier formatting options
* @param stopAfter Optional node to stop formatting after
* @returns The formatted subtree, or undefined if formatting failed
*/
export async function prettierFormatSubtree(
target: T,
cursor: Cursor,
options: PrettierFormatOptions = {},
stopAfter?: J
): Promise {
// Extract the path and compilation unit in a single cursor traversal
const { compilationUnit: cu, path } = extractPathFromCursor(cursor, target);
if (!cu) {
return undefined;
}
// Prune the tree for efficient formatting and substitute the (potentially modified) target.
// This ensures that if the visitor modified the target before calling autoFormat,
// we format the modified content, not the original from the cursor.
const prunedCu = pruneTreeForSubtree(cu, path, target);
// Format the pruned compilation unit with Prettier
const formattedPrunedCu = await prettierFormat(prunedCu, options);
// Find the target node in the formatted tree using the path
const formattedTarget = findByPath(formattedPrunedCu, path);
if (!formattedTarget) {
return undefined;
}
// Reconcile only the target subtree, optionally stopping after a specific node
const reconciler = new WhitespaceReconciler();
const reconciled = reconciler.reconcile(target as J, formattedTarget as J, undefined, stopAfter);
return reconciled as T;
}
/**
* Gets the PrettierStyle from the styles array or source file markers.
*
* @param tree The tree being formatted
* @param cursor Optional cursor for walking up to find source file
* @param styles Optional styles array to check first
* @returns PrettierStyle if found, undefined otherwise
*/
export function getPrettierStyle(
tree: Tree,
cursor?: Cursor,
styles?: NamedStyles[]
): PrettierStyle | undefined {
// First check the styles array
if (styles) {
const fromStyles = styles.find(s => (s as any).kind === StyleKind.PrettierStyle);
if (fromStyles) {
return fromStyles as unknown as PrettierStyle;
}
}
// Then check for PrettierStyle marker on source file
let sourceFile: JS.CompilationUnit | undefined;
if (tree.kind === JS.Kind.CompilationUnit) {
sourceFile = tree as JS.CompilationUnit;
} else if (cursor) {
// Walk up the cursor to find the compilation unit
let current: Cursor | undefined = cursor;
while (current) {
if (current.value?.kind === JS.Kind.CompilationUnit) {
sourceFile = current.value as JS.CompilationUnit;
break;
}
current = current.parent;
}
}
if (!sourceFile) {
return undefined;
}
return findMarker(sourceFile, StyleKind.PrettierStyle) as PrettierStyle | undefined;
}
/**
* Applies Prettier formatting to a tree.
*
* Configuration is resolved from the PrettierStyle marker on the source file.
*
* For compilation units, formats and reconciles the entire tree.
* For subtrees, uses prettierFormatSubtree which prunes the tree for efficiency,
* formats the pruned tree, and reconciles only the target subtree.
*
* @param tree The tree to format
* @param prettierStyle The PrettierStyle containing config
* @param p The visitor parameter
* @param cursor Optional cursor for subtree formatting
* @param stopAfter Optional tree to stop after
* @returns The formatted tree
*/
export async function applyPrettierFormatting(
tree: R,
prettierStyle: PrettierStyle,
p: P,
cursor?: Cursor,
stopAfter?: Tree
): Promise {
// Run only the essential visitors first
const essentialVisitors = [
new NormalizeWhitespaceVisitor(stopAfter),
new MinimumViableSpacingVisitor(stopAfter),
];
let t: R | undefined = tree;
for (const visitor of essentialVisitors) {
t = await visitor.visit(t, p, cursor);
if (t === undefined) {
return undefined;
}
}
// If file is in .prettierignore, skip formatting entirely
if (prettierStyle.ignored) {
return t;
}
// Build options for Prettier
// Pass through the entire resolved config - let Prettier use its own defaults for unspecified options
const prettierOpts: PrettierFormatOptions = {
...prettierStyle.config as PrettierFormatOptions,
prettierVersion: prettierStyle.prettierVersion,
};
try {
if (t.kind === JS.Kind.CompilationUnit) {
// Format and reconcile the entire compilation unit
const formatted = await prettierFormat(t as unknown as JS.CompilationUnit, prettierOpts, stopAfter as J | undefined);
return formatted as unknown as R;
}
if (!cursor) {
// No cursor provided - can't use subtree formatting, return with essential formatting
console.warn('Prettier formatting: No cursor provided for subtree, returning with essential formatting only');
return t;
}
// Use prettierFormatSubtree for subtree formatting
const formatted = await prettierFormatSubtree(t, cursor, prettierOpts, stopAfter as J | undefined);
if (formatted) {
return formatted as R;
}
// Subtree formatting failed, return with essential formatting applied
console.warn('Prettier formatting: Subtree formatting failed, returning with essential formatting only');
return t;
} catch (e) {
// If Prettier fails, return tree with essential formatting applied
console.warn('Prettier formatting failed, returning with essential formatting only:', e);
return t;
}
}