/*
* 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 {isIdentifier, isLiteral, isSpace, J, TextComment, Type} from '../../java';
import {JS} from "../tree";
/**
* Union type for all tree node types that the reconciler handles.
* This includes J nodes and their wrapper types.
*/
type TreeNode = J | J.RightPadded | J.LeftPadded | J.Container | J.Space;
/**
* Type guard to check if a value has a kind property (is a tree node or wrapper).
*/
function hasKind(value: unknown): value is { kind: string } {
return value !== null &&
typeof value === 'object' &&
'kind' in value &&
typeof (value as { kind: unknown }).kind === 'string';
}
/**
* Tree nodes that can be visited by visitNode (excludes Space which is handled separately).
*/
type VisitableNode = Exclude;
/**
* Type guard to check if a value is a VisitableNode (J, LeftPadded, RightPadded, or Container).
* This is used after hasKind() to further narrow the type for visitNode().
*/
function isVisitableNode(value: { kind: string }): value is VisitableNode {
const kind = value.kind;
// Check for wrapper kinds first (more specific)
if (kind === J.Kind.LeftPadded || kind === J.Kind.RightPadded || kind === J.Kind.Container) {
return true;
}
// All other non-Space tree nodes with valid kind strings are J nodes
// Space is handled separately before this check
return kind !== J.Kind.Space && !kind.startsWith('org.openrewrite.java.tree.JavaType$');
}
/**
* A visitor that reconciles whitespace from a formatted tree into the original tree.
* Walks both trees in parallel and copies whitespace (prefix, before, after) from
* the formatted tree to the original.
*
* The result preserves the original AST's structure, types, and markers while
* applying the formatted tree's whitespace.
*
* When a target subtree is specified, the reconciler only applies whitespace changes
* to that subtree and its descendants, leaving surrounding code unchanged.
*
* When stopAfter is specified, the reconciler stops applying changes after exiting
* that node, leaving all subsequent nodes with their original whitespace.
*/
export class WhitespaceReconciler {
/**
* Flag indicating whether the trees have compatible structure.
*/
private compatible: boolean = true;
/**
* The subtree to reconcile (by reference). If undefined, reconcile everything.
* Can be a J node, RightPadded, LeftPadded, or Container.
*/
private targetSubtree?: TreeNode;
/**
* The node to stop after (by reference). Once we exit this node, stop reconciling.
* Can be a J node, RightPadded, LeftPadded, or Container.
*/
private stopAfterNode?: TreeNode;
/**
* Tracks the reconciliation state:
* - 'searching': Walking but not yet inside target subtree
* - 'reconciling': Inside target subtree, applying changes
* - 'done': Exited target subtree or stopAfter node, no more changes
*/
private reconcileState: 'searching' | 'reconciling' | 'done' = 'reconciling';
/**
* Reconciles whitespace from a formatted tree into the original tree.
*
* @param original The original tree (with types, markers, etc.)
* @param formatted The formatted tree (with desired whitespace)
* @param targetSubtree Optional subtree to limit reconciliation to (by reference).
* Can be a J node, RightPadded, LeftPadded, or Container.
* If provided, only this subtree and its descendants will have
* whitespace and markers applied.
* @param stopAfter Optional node to stop reconciliation after (by reference).
* Once we exit this node, no more changes are applied.
* @returns The original tree with whitespace from the formatted tree
*/
reconcile(original: J, formatted: J, targetSubtree?: TreeNode, stopAfter?: TreeNode): J {
this.compatible = true;
this.targetSubtree = targetSubtree;
this.stopAfterNode = stopAfter;
this.reconcileState = targetSubtree ? 'searching' : 'reconciling';
// We know original and formatted are J nodes, so result will be J
return this.visitNode(original, formatted) as J;
}
/**
* Returns whether the reconciliation was successful (structures were compatible).
*/
isCompatible(): boolean {
return this.compatible;
}
/**
* Marks structure as incompatible and returns the original unchanged.
*/
private structureMismatch(t: T): T {
this.compatible = false;
return t;
}
/**
* Visit a property value, handling all the different types appropriately.
* This is the central entry point for visiting any node, including wrappers.
*
* @returns The reconciled value (original structure with formatted whitespace)
*/
private visitProperty(original: unknown, formatted: unknown): unknown {
// Handle null/undefined
if (original == null || formatted == null) {
if (original !== formatted) {
return this.structureMismatch(original);
}
return original;
}
// Type nodes - short-circuit, keep original (types are expensive to compute)
// Check this first as isType already validates the kind property
if (Type.isType(original)) {
return original;
}
// Check if this is a tree node (has a kind property)
if (!hasKind(original)) {
// Primitive values or non-tree objects - copy from formatted when reconciling
// This handles things like valueSource (quote style) which is formatting
if (this.shouldReconcile() && formatted !== original) {
return formatted;
}
return original;
}
// Space nodes - copy when reconciling, don't recurse
if (isSpace(original)) {
if (!this.shouldReconcile()) return original;
if (isSpace(formatted) && this.spacesEqual(original, formatted as J.Space)) return original;
return formatted;
}
// Track entering target subtree (using referential equality)
const isTargetSubtree = this.targetSubtree !== undefined && original === this.targetSubtree;
const isStopAfterNode = this.stopAfterNode !== undefined && original === this.stopAfterNode;
const previousState = this.reconcileState;
if (isTargetSubtree && this.reconcileState === 'searching') {
this.reconcileState = 'reconciling';
}
try {
// All tree nodes (J, RightPadded, LeftPadded, Container) go through visitNode
// After hasKind() and isSpace()/isType() checks, we know this is a VisitableNode
if (!isVisitableNode(original) || !hasKind(formatted) || !isVisitableNode(formatted)) {
return this.structureMismatch(original);
}
return this.visitNode(original, formatted);
} finally {
// Track exiting the target subtree
if (isTargetSubtree && previousState === 'searching') {
this.reconcileState = 'done';
}
// Track exiting the stopAfter node - stop reconciling after this
if (isStopAfterNode && previousState === 'reconciling') {
this.reconcileState = 'done';
}
}
}
/**
* Visit all properties of a tree node (J, RightPadded, LeftPadded, Container).
* Copies Space values and markers when reconciling, visits everything else.
*
* Note: The return type may differ from the input type in cases of semantic
* equivalence (e.g., Identifier↔Literal with quoteProps), but will always
* be a valid VisitableNode.
*
* @param original Tree node with kind property
* @param formatted Corresponding formatted tree node
* @returns The original with whitespace from formatted applied
*/
private visitNode(
original: VisitableNode,
formatted: VisitableNode
): VisitableNode {
if (!this.compatible) {
return original;
}
// Check if kinds match
if (original.kind !== formatted.kind) {
// Check if this is a valid semantic equivalence (e.g., quoteProps changing Identifier↔Literal)
if (this.shouldReconcile() && this.isSemanticEquivalent(original, formatted)) {
// Use the formatted node but preserve type information from original
// isSemanticEquivalent only returns true for Identifier/Literal pairs
return this.copyWithPreservedTypes(
original as J.Identifier | J.Literal,
formatted as J.Identifier | J.Literal
);
}
return this.structureMismatch(original);
}
let result: VisitableNode = original;
// Visit all properties
for (const key of Object.keys(original)) {
// Skip: kind, id, type properties
if (key === 'kind' || key === 'id' ||
key === 'type' || key === 'fieldType' || key === 'variableType' ||
key === 'methodType' || key === 'constructorType' ||
original.kind === JS.Kind.CompilationUnit && key == 'charsetName' ||
// TODO In Java `null` and `undefined` are both the same
original.kind === J.Kind.Literal && key === 'value') {
continue;
}
const originalValue = (original as Record)[key];
const formattedValue = (formatted as Record)[key];
// Space values and markers: copy from formatted when reconciling
if ((isSpace(originalValue)) || key === 'markers') {
if (this.shouldReconcile() && formattedValue !== originalValue) {
// For spaces, check structural equality to avoid unnecessary new objects
if (isSpace(originalValue) && isSpace(formattedValue) && this.spacesEqual(originalValue, formattedValue as J.Space)) {
continue;
}
result = { ...result, [key]: formattedValue } as VisitableNode;
}
continue;
}
// Handle arrays
if (Array.isArray(originalValue)) {
if (!Array.isArray(formattedValue) || originalValue.length !== formattedValue.length) {
if (originalValue.length === 0 && formattedValue === undefined && original.kind == J.Kind.ArrayType) {
// TODO Somehow J.ArrayType#annotations ends up as `[]`
continue;
}
return this.structureMismatch(original);
}
const newArray: unknown[] = [];
let changed = false;
for (let i = 0; i < originalValue.length; i++) {
const visited = this.visitProperty(originalValue[i], formattedValue[i]);
if (!this.compatible) return original;
newArray.push(visited);
if (visited !== originalValue[i]) {
changed = true;
}
}
if (changed) {
result = { ...result, [key]: newArray } as VisitableNode;
}
} else {
// Visit the property
const visited = this.visitProperty(originalValue, formattedValue);
if (!this.compatible) return original;
if (visited !== originalValue) {
result = { ...result, [key]: visited } as VisitableNode;
}
}
}
return result;
}
/**
* Check if we should apply whitespace changes at the current position.
*/
private shouldReconcile(): boolean {
return this.reconcileState === 'reconciling';
}
/**
* Structurally compare two Space objects for equality.
* Currently only TextComment has additional properties (text, multiline);
* if new comment types are added, extend the comparison here.
*/
private spacesEqual(a: J.Space, b: J.Space): boolean {
if (a.whitespace !== b.whitespace) return false;
if (a.comments.length !== b.comments.length) return false;
for (let i = 0; i < a.comments.length; i++) {
const ca = a.comments[i], cb = b.comments[i];
if (ca.kind !== cb.kind || ca.suffix !== cb.suffix) return false;
if (ca.kind === J.Kind.TextComment) {
const ta = ca as TextComment, tb = cb as TextComment;
if (ta.text !== tb.text || ta.multiline !== tb.multiline) return false;
}
}
return true;
}
/**
* Checks if two nodes with different kinds are semantically equivalent.
* This handles cases like Prettier's quoteProps option which can change
* property names between Identifier and Literal forms.
*/
private isSemanticEquivalent(original: VisitableNode, formatted: VisitableNode): boolean {
// Identifier → Literal equivalence (for property names with quoteProps)
if (isIdentifier(original) && isLiteral(formatted)) {
return original.simpleName === formatted.value;
}
// Literal → Identifier equivalence
if (isLiteral(original) && isIdentifier(formatted)) {
return original.value === formatted.simpleName;
}
return false;
}
/**
* Creates a copy of the formatted node with type information preserved from the original.
* Used when we accept a structural change from Prettier (Identifier ↔ Literal)
* but need to keep type attribution from the original.
*
* Only preserves `type` and `fieldType` since those are the only type-related
* fields on J.Identifier and J.Literal.
*/
private copyWithPreservedTypes(
original: J.Identifier | J.Literal,
formatted: J.Identifier | J.Literal
): J.Identifier | J.Literal {
const result: Record = { ...formatted };
// Preserve type attribution - both Identifier and Literal have `type`
if (original.type !== undefined) {
result.type = original.type;
}
// Preserve fieldType - only Identifier has this, but safe to check
if ('fieldType' in original && original.fieldType !== undefined) {
result.fieldType = original.fieldType;
}
// Cast via unknown since we're modifying a copy of a valid Identifier/Literal
return result as unknown as J.Identifier | J.Literal;
}
}