/* * 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, JSX} from "../tree"; import {JavaScriptVisitor} from "../visitor"; import { isJava, isSpace, J, lastWhitespace, normalizeSpaceIndent, replaceIndentAfterLastNewline, replaceLastWhitespace, spaceContainsNewline, stripLeadingIndent } from "../../java"; import {create as produce} from "mutative"; import {Cursor, isScope, isTree, Tree} from "../../tree"; import {mapAsync} from "../../util"; import {produceAsync} from "../../visitor"; import {TabsAndIndentsStyle} from "../style"; import {findMarker} from "../../markers"; type IndentKind = 'block' | 'continuation' | 'align'; type IndentContext = [number, IndentKind]; // [indent, kind] export class TabsAndIndentsVisitor

extends JavaScriptVisitor

{ private readonly indentSize: number; private readonly useTabCharacter: boolean; constructor(private readonly tabsAndIndentsStyle: TabsAndIndentsStyle, private stopAfter?: Tree) { super(); this.indentSize = this.tabsAndIndentsStyle.indentSize; this.useTabCharacter = this.tabsAndIndentsStyle.useTabCharacter; } private indentString(indent: number): string { if (this.useTabCharacter) { return "\t".repeat(Math.floor(indent / this.indentSize)); } return " ".repeat(indent); } protected async preVisit(tree: J, _p: P): Promise { this.setupCursorMessagesForTree(this.cursor, tree); return tree; } private setupCursorMessagesForTree(cursor: Cursor, tree: J): void { // Check if this MethodInvocation starts a method chain (select.after has newline) if (tree.kind === J.Kind.MethodInvocation) { const mi = tree as J.MethodInvocation; if (mi.select && mi.select.after.whitespace.includes("\n")) { // This MethodInvocation has a chained method call after it // Store the BASE indent context in "chainedIndentContext" // This will be propagated down and used when we reach the chain's innermost element const [parentIndent, parentIndentKind] = this.getParentIndentContext(cursor); // If this chain-start itself is on a new line and we're in continuation context, // the base indent includes continuation. // BUT if parentIndentKind is 'align', we're likely in a Block child context where // the parent already set the correct indent - don't add extra continuation. const prefixHasNewline = this.prefixContainsNewline(tree); let baseIndent = parentIndent; if (prefixHasNewline && parentIndentKind !== 'align') { baseIndent += this.indentSize; } // If we're inside a Lambda expression body that's inside a Container (like method arguments), // add another continuation for the Container context const isLambdaBodyContainer = this.isLambdaBodyInsideContainer(cursor); if (prefixHasNewline && isLambdaBodyContainer) { baseIndent += this.indentSize; } cursor.messages.set("chainedIndentContext", [baseIndent, parentIndentKind] as IndentContext); // For children (arguments), use continuation indent from base cursor.messages.set("indentContext", [baseIndent + this.indentSize, parentIndentKind] as IndentContext); return; } // Check if we're at the base of a chain (no select) and parent has chainedIndentContext if (!mi.select) { const chainedContext = cursor.parent?.messages.get("chainedIndentContext") as IndentContext | undefined; if (chainedContext !== undefined) { // Consume the chainedIndentContext - this is the base of the chain // The base element gets the original indent (no extra continuation) // Propagate chainedIndentContext so the `name` child knows not to add indent cursor.messages.set("indentContext", chainedContext); cursor.messages.set("chainedIndentContext", chainedContext); return; } } } // Check if we're the `name` of a MethodInvocation at the base of a chain if (tree.kind === J.Kind.Identifier) { const parentValue = cursor.parent?.value; if (parentValue?.kind === J.Kind.MethodInvocation) { const parentMi = parentValue as J.MethodInvocation; // Check if parent has chainedIndentContext (meaning it's at the base of a chain) // and we are the `name` (not an argument or something else) const parentChainedContext = cursor.parent?.messages.get("chainedIndentContext") as IndentContext | undefined; if (parentChainedContext !== undefined && !parentMi.select) { // We're the name of a chain-base MethodInvocation - use the chained indent directly cursor.messages.set("indentContext", parentChainedContext); return; } } } const [parentMyIndent, parentIndentKind] = this.getParentIndentContext(cursor); const myIndent = this.computeMyIndent(tree, parentMyIndent, parentIndentKind); // For Binary, behavior depends on whether it's already on a continuation line let indentKind: IndentKind; if (tree.kind === J.Kind.Binary) { // If Binary has newline OR parent is in align mode, children align const hasNewline = this.prefixContainsNewline(tree); indentKind = (hasNewline || parentIndentKind === 'align') ? 'align' : 'continuation'; } else { indentKind = this.computeIndentKind(tree); } cursor.messages.set("indentContext", [myIndent, indentKind] as IndentContext); } private getParentIndentContext(cursor: Cursor): IndentContext { // Walk up the cursor chain to find the nearest indent context // We need to walk because intermediate nodes like RightPadded may not have context set let passedScopeBoundary = false; for (let c = cursor.parent; c != null; c = c.parent) { // Check if we're passing a scope boundary (Lambda creates a new scope) // After crossing a Lambda, we should ignore chainedIndentContext from outer scopes const nodeKind = (c.value as any)?.kind; if (nodeKind === J.Kind.Lambda) { passedScopeBoundary = true; } // chainedIndentContext stores the original context - prefer it // BUT only if we haven't crossed a scope boundary (like Lambda) if (!passedScopeBoundary) { const chainedContext = c.messages.get("chainedIndentContext") as IndentContext | undefined; if (chainedContext !== undefined) { return chainedContext; } } const context = c.messages.get("indentContext") as IndentContext | undefined; if (context !== undefined) { return context; } } return [0, 'continuation']; } private computeMyIndent(tree: J, parentMyIndent: number, parentIndentKind: IndentKind): number { // CompilationUnit is the root - it always has myIndent=0 regardless of prefix content if (tree.kind === JS.Kind.CompilationUnit) { return 0; } // TemplateExpressionSpan: reset indent context - template literal content determines its own indentation // The expression inside ${...} should be indented based on where it appears in the template, not outer code if (tree.kind === JS.Kind.TemplateExpressionSpan) { // Extract base indent from the expression's prefix whitespace (after the last newline) const span = tree as JS.TemplateExpression.Span; const prefix = span.expression?.prefix?.whitespace ?? ""; const lastNewline = prefix.lastIndexOf("\n"); if (lastNewline >= 0) { return prefix.length - lastNewline - 1; } return 0; } if (tree.kind === J.Kind.IfElse || parentIndentKind === 'align') { return parentMyIndent; } // Certain structures don't add indent for themselves - they stay at parent level // - TryCatch: catch clause is part of try statement // - TypeLiteral: { members } in type definitions // - EnumValueSet: enum members get same indent as the set itself if (tree.kind === J.Kind.TryCatch || tree.kind === JS.Kind.TypeLiteral || tree.kind === J.Kind.EnumValueSet) { return parentMyIndent; } // Only add indent if this element starts on a new line // Check both the element's prefix and any Spread marker's prefix const hasNewline = this.prefixContainsNewline(tree); if (!hasNewline) { // Special case for JSX: children of JsxTag don't have newlines in their prefix // (newlines are in text Literal nodes), but JSX children should still get block indent if (this.isJsxChildElement(tree)) { return parentMyIndent + this.indentSize; } return parentMyIndent; } // Add indent for block children or continuation return parentMyIndent + this.indentSize; } private prefixContainsNewline(tree: J): boolean { // Check if the element starts on a new line (only the last whitespace matters) if (tree.prefix && lastWhitespace(tree.prefix).includes("\n")) { return true; } return false; } private isJsxChildElement(tree: J): boolean { // Check if this is a JSX child element whose parent is a JsxTag // JSX children (JsxTag, JsxEmbeddedExpression) don't have newlines in their own prefix // (newlines are in text Literal nodes), but they should still get block indent if (tree.kind !== JS.Kind.JsxTag && tree.kind !== JS.Kind.JsxEmbeddedExpression) { return false; } const parentTree = this.cursor.parentTree(); return parentTree !== undefined && parentTree.value.kind === JS.Kind.JsxTag; } private computeIndentKind(tree: J): IndentKind { switch (tree.kind) { case J.Kind.Block: case J.Kind.Case: case JS.Kind.JsxTag: case JS.Kind.TypeLiteral: case J.Kind.EnumValueSet: return 'block'; case JS.Kind.CompilationUnit: return 'align'; default: return 'continuation'; } } private isLambdaBodyInsideContainer(cursor: Cursor): boolean { // Check if we're the DIRECT Lambda body (not inside a Block that's the Lambda body) // AND that Lambda is inside a Container (method arguments) // Walk up to find Lambda, but stop if we hit a Block (meaning we're inside a block body, not expression body) let foundLambda = false; for (let c = cursor.parent; c != null; c = c.parent) { const kind = (c.value as any)?.kind; // If we hit a Block before Lambda, we're inside the block body, not expression body if (!foundLambda && kind === J.Kind.Block) { return false; } if (kind === J.Kind.Lambda) { foundLambda = true; } // After finding Lambda, check for Container if (foundLambda && kind && kind === J.Kind.Container) { return true; } } return false; } override async postVisit(tree: J, _p: P): Promise { if (this.stopAfter != null && isScope(this.stopAfter, tree)) { this.cursor?.root.messages.set("stop", true); } // If parent has chainedIndentContext but no indentContext yet, we're exiting a chain element // Set indentContext on parent = [chainedIndent + indentSize, chainedIndentKind] // This only happens once at the innermost element; subsequent parents will already have indentContext const parentChainedContext = this.cursor.parent?.messages.get("chainedIndentContext") as IndentContext | undefined; const parentHasIndentContext = this.cursor.parent?.messages.has("indentContext"); if (parentChainedContext !== undefined && !parentHasIndentContext) { const [chainedIndent, chainedIndentKind] = parentChainedContext; this.cursor.parent!.messages.set("indentContext", [chainedIndent + this.indentSize, chainedIndentKind] as IndentContext); } const indentContext = this.cursor.messages.get("indentContext") as IndentContext | undefined; if (indentContext === undefined) { return tree; } let [myIndent] = indentContext; // For chain-start MethodInvocations, the prefix contains whitespace before the chain BASE // Use chainedIndentContext for the prefix - it already accounts for newlines const chainedContext = this.cursor.messages.get("chainedIndentContext") as IndentContext | undefined; if (chainedContext !== undefined && tree.kind === J.Kind.MethodInvocation) { const mi = tree as J.MethodInvocation; if (mi.select && mi.select.after.whitespace.includes("\n")) { // This is a chain-start - use the base indent from chainedIndentContext myIndent = chainedContext[0]; } } let result = tree; const indentStr = this.indentString(myIndent); // Normalize the prefix space including comment suffixes if (result.prefix && spaceContainsNewline(result.prefix)) { const normalizedPrefix = normalizeSpaceIndent(result.prefix, indentStr); if (normalizedPrefix !== result.prefix) { result = produce(result, draft => { draft.prefix = normalizedPrefix; }); } } if (result.kind === J.Kind.Block) { result = this.normalizeBlockEnd(result as J.Block, myIndent); } else if (result.kind === J.Kind.Literal && this.isInsideJsxTag()) { result = this.normalizeJsxTextContent(result as J.Literal, myIndent); } return result; } private isInsideJsxTag(): boolean { const parentTree = this.cursor.parentTree(); return parentTree !== undefined && parentTree.value.kind === JS.Kind.JsxTag; } private normalizeJsxTextContent(literal: J.Literal, myIndent: number): J.Literal { if (!literal.valueSource || !literal.valueSource.includes("\n")) { return literal; } // Check if this literal is the last child of a JsxTag - if so, its trailing whitespace // should use the parent tag's indent, not the content indent const parentContext = this.cursor.parentTree()!.messages.get("indentContext") as IndentContext | undefined; const parentIndent = parentContext?.[0]; const isLastChild = parentIndent !== undefined && this.isLastChildOfJsxTag(literal); // For JSX text content, the newline is in the value, not the prefix. // Since the content IS effectively on a new line, it should get block child indent. // myIndent is the parent's indent (because Literal prefix has no newline), // so we need to add indentSize for content lines. const contentIndent = myIndent + this.indentSize; // Split by newlines and normalize each line's indentation const lines = literal.valueSource.split('\n'); const result: string[] = []; for (let i = 0; i < lines.length; i++) { if (i === 0) { // Content before first newline stays as-is result.push(lines[i]); continue; } const content = stripLeadingIndent(lines[i]); if (content === '') { // Line has only whitespace (or is empty) if (isLastChild && i === lines.length - 1) { // Trailing whitespace of last child - use parent indent for closing tag alignment result.push(this.indentString(parentIndent!)); } else if (i < lines.length - 1) { // Empty line in the middle (followed by more lines) - keep empty result.push(''); } else { // Trailing whitespace of non-last-child - add content indent result.push(this.indentString(contentIndent)); } } else { // Line has content - add proper indent result.push(this.indentString(contentIndent) + content); } } const normalizedValueSource = result.join('\n'); if (normalizedValueSource === literal.valueSource) { return literal; } return produce(literal, draft => { draft.valueSource = normalizedValueSource; }); } private isLastChildOfJsxTag(literal: J.Literal): boolean { const parentCursor = this.cursor.parentTree(); if (parentCursor && parentCursor.value.kind === JS.Kind.JsxTag) { const tag = parentCursor.value as JSX.Tag; if (tag.children && tag.children.length > 0) { const lastChild = tag.children[tag.children.length - 1]; // Compare by id since object references might differ after transformations return lastChild.kind === J.Kind.Literal && (lastChild as J.Literal).id === literal.id; } } return false; } private normalizeBlockEnd(block: J.Block, myIndent: number): J.Block { const effectiveLastWs = lastWhitespace(block.end); if (!effectiveLastWs.includes("\n")) { return block; } return produce(block, draft => { draft.end = replaceLastWhitespace(draft.end, ws => replaceIndentAfterLastNewline(ws, this.indentString(myIndent))); }); } public async visitContainer(container: J.Container, p: P): Promise> { // Create cursor for this container this.cursor = new Cursor(container, this.cursor); // Pre-visit hook: set up cursor messages this.preVisitContainer(); // Visit children (similar to base visitor but without cursor management) let ret = (await produceAsync>(container, async draft => { draft.before = await this.visitSpace(container.before, p); (draft.elements as J.RightPadded[]) = await mapAsync(container.elements, e => this.visitRightPadded(e, p)); draft.markers = await this.visitMarkers(container.markers, p); }))!; // Post-visit hook: normalize indentation ret = this.postVisitContainer(ret); // Restore cursor this.cursor = this.cursor.parent!; return ret; } private preVisitContainer(): void { const parentContext = this.cursor.parent?.messages.get("indentContext") as IndentContext | undefined; const [myIndent] = parentContext ?? [0, 'continuation']; this.cursor.messages.set("indentContext", [myIndent, 'continuation'] as IndentContext); } private postVisitContainer(container: J.Container): J.Container { const parentContext = this.cursor.parent?.messages.get("indentContext") as IndentContext | undefined; const [parentIndent] = parentContext ?? [0, 'continuation']; // Normalize the last element's after whitespace (closing delimiter like `)`) // The closing delimiter should align with the parent's indent level if (container.elements.length > 0) { const effectiveLastWs = lastWhitespace(container.elements[container.elements.length - 1].after); if (effectiveLastWs.includes("\n")) { return produce(container, draft => { const lastDraft = draft.elements[draft.elements.length - 1]; lastDraft.after = replaceLastWhitespace(lastDraft.after, ws => replaceIndentAfterLastNewline(ws, this.indentString(parentIndent))); }); } } return container; } public async visitLeftPadded( left: J.LeftPadded, p: P ): Promise | undefined> { // Create cursor for this LeftPadded this.cursor = new Cursor(left, this.cursor); // Pre-visit hook: set up cursor messages this.preVisitLeftPadded(left); // Visit children (similar to base visitor but without cursor management) let ret = await produceAsync>(left, async draft => { draft.before = await this.visitSpace(left.before, p); if (isTree(left.element)) { (draft.element as J) = await this.visitDefined(left.element, p); } else if (isSpace(left.element)) { (draft.element as J.Space) = await this.visitSpace(left.element, p); } draft.markers = await this.visitMarkers(left.markers, p); }); // Post-visit hook: normalize indentation ret = this.postVisitLeftPadded(ret); // Restore cursor this.cursor = this.cursor.parent!; return ret; } private preVisitLeftPadded(left: J.LeftPadded): void { const parentContext = this.cursor.parent?.messages.get("indentContext") as IndentContext | undefined; const [parentIndent, parentIndentKind] = parentContext ?? [0, 'continuation']; const hasNewline = left.before.whitespace.includes("\n"); // Check if parent is a Binary in align mode - if so, don't add continuation indent const parentValue = this.cursor.parent?.value; const shouldAlign = parentValue?.kind === J.Kind.Binary && parentIndentKind === 'align'; // Compute myIndent INCLUDING continuation if applicable let myIndent = parentIndent; if (hasNewline && !shouldAlign) { myIndent = parentIndent + this.indentSize; } this.cursor.messages.set("indentContext", [myIndent, 'continuation'] as IndentContext); this.cursor.messages.set("hasNewline", hasNewline); } private postVisitLeftPadded( left: J.LeftPadded | undefined ): J.LeftPadded | undefined { if (left === undefined) { return undefined; } const hasNewline = this.cursor.messages.get("hasNewline") as boolean ?? false; if (!hasNewline) { return left; } // Use the indent we computed in preVisitLeftPadded (which includes continuation if applicable) const context = this.cursor.messages.get("indentContext") as IndentContext | undefined; const [targetIndent] = context ?? [0, 'continuation']; return produce(left, draft => { draft.before.whitespace = replaceIndentAfterLastNewline(draft.before.whitespace, this.indentString(targetIndent)); }); } public async visitRightPadded( right: J.RightPadded, p: P ): Promise | undefined> { // Create cursor for this RightPadded this.cursor = new Cursor(right, this.cursor); // Pre-visit hook: set up cursor messages, propagate continuation if after has newline this.preVisitRightPadded(right); // Visit children (similar to base visitor but without cursor management) let ret = await produceAsync>(right, async draft => { if (isTree(right.element)) { (draft.element as J) = await this.visitDefined(right.element, p); } draft.after = await this.visitSpace(right.after, p); draft.markers = await this.visitMarkers(right.markers, p); }); // Restore cursor this.cursor = this.cursor.parent!; if (ret?.element === undefined) { return undefined; } return ret; } private preVisitRightPadded(right: J.RightPadded): void { const parentContext = this.cursor.parent?.messages.get("indentContext") as IndentContext | undefined; const [parentIndent, parentIndentKind] = parentContext ?? [0, 'continuation']; // Check if parent has chainedIndentContext - if so, this is the select of a method chain // Propagate chainedIndentContext but do NOT set indentContext // EXCEPTION: Do NOT propagate into Lambda bodies - arrow functions create a new scope const parentChainedContext = this.cursor.parent?.messages.get("chainedIndentContext") as IndentContext | undefined; const elementKind = (right.element as any)?.kind; const isLambdaBody = elementKind === J.Kind.Lambda; if (parentChainedContext !== undefined && !isLambdaBody) { this.cursor.messages.set("chainedIndentContext", parentChainedContext); // Do NOT set indentContext - child elements will use chainedIndentContext return; } // Check if Parentheses wraps a Binary expression - if so, let Binary handle its own indent const rightPaddedParentKind = this.cursor.parent?.value?.kind; const isParenthesesWrappingBinary = rightPaddedParentKind === J.Kind.Parentheses && elementKind === J.Kind.Binary; // Check if this is a NamedVariable inside a VariableDeclarations with decorators // In this case, the newline between decorator and variable name should NOT cause continuation indent const isVariableAfterDecorator = elementKind === J.Kind.NamedVariable && rightPaddedParentKind === J.Kind.VariableDeclarations && ((this.cursor.parent?.value as J.VariableDeclarations)?.leadingAnnotations?.length ?? 0) > 0; let myIndent = parentIndent; if (!isParenthesesWrappingBinary && !isVariableAfterDecorator && parentIndentKind !== 'align' && isJava(right.element) && this.prefixContainsNewline(right.element as J)) { myIndent = parentIndent + this.indentSize; // For spread elements with newlines, mark continuation as established const element = right.element as J; if (this.isSpreadElement(element)) { this.cursor.parent?.messages.set("continuationIndent", myIndent); } } else if (isJava(right.element) && !this.prefixContainsNewline(right.element as J)) { // Element has no newline - check if a previous sibling established continuation const continuationIndent = this.cursor.parent?.messages.get("continuationIndent") as number | undefined; if (continuationIndent !== undefined) { myIndent = continuationIndent; } } // Set 'align' for most RightPadded elements to prevent double-continuation // EXCEPT when Parentheses wraps a Binary expression - use continuation so Binary children align const indentKind: IndentKind = isParenthesesWrappingBinary ? 'continuation' : 'align'; this.cursor.messages.set("indentContext", [myIndent, indentKind] as IndentContext); } /** * Check if an element is a spread - either a JS.Spread element * or a PropertyAssignment wrapping a spread. */ private isSpreadElement(element: J): boolean { // JS.Spread AST element (for spread/rest expressions like `...arr`, `...obj`, `...args`) if (element.kind === JS.Kind.Spread) { return true; } // PropertyAssignment wrapping a spread (for object spread like `...obj`) if (element.kind === JS.Kind.PropertyAssignment) { const propAssign = element as JS.PropertyAssignment; const nameElement = propAssign.name?.element; if (nameElement?.kind === JS.Kind.Spread) { return true; } } return false; } async visit(tree: Tree, p: P, parent?: Cursor): Promise { if (this.cursor?.getNearestMessage("stop") != null) { return tree as R; } if (parent) { this.cursor = new Cursor(tree, parent); this.setupAncestorIndents(); } return await super.visit(tree, p) as R; } private setupAncestorIndents(): void { const path: Cursor[] = []; let anchorCursor: Cursor | undefined; let anchorIndent = 0; for (let c = this.cursor.parent; c; c = c.parent) { path.push(c); const v = c.value; if (this.isActualJNode(v) && !anchorCursor && v.prefix) { const ws = lastWhitespace(v.prefix); const idx = ws.lastIndexOf('\n'); if (idx !== -1) { anchorCursor = c; anchorIndent = ws.length - idx - 1; } } if (v.kind === JS.Kind.CompilationUnit) { if (!anchorCursor) { anchorCursor = c; anchorIndent = 0; } break; } } if (path.length === 0) return; path.reverse(); for (const c of path) { const v = c.value; if (!this.isActualJNode(v)) continue; const savedCursor = this.cursor; this.cursor = c; if (c === anchorCursor) { c.messages.set("indentContext", [anchorIndent, this.computeIndentKind(v)] as IndentContext); } else { this.setupCursorMessagesForTree(c, v); } this.cursor = savedCursor; } } private isActualJNode(v: any): v is J { return isJava(v) && v.kind !== J.Kind.Container && v.kind !== J.Kind.LeftPadded && v.kind !== J.Kind.RightPadded; } }