/* * 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 {Recipe} from "../../recipe"; import {TreeVisitor} from "../../visitor"; import {ExecutionContext} from "../../execution"; import {JavaScriptVisitor} from "../visitor"; import {emptySpace, Expression, J} from "../../java"; import {JS} from "../tree"; import {findMarker, markers} from "../../markers"; import {randomId} from "../../uuid"; import {Optional} from "../markers"; /** * Converts ternary expressions that check for null/undefined into optional chaining. * * Examples: * - `foo ? foo.bar : undefined` becomes `foo?.bar` * - `foo ? foo.bar : null` becomes `foo?.bar ?? null` * - `obj ? obj.method() : undefined` becomes `obj?.method()` * - `arr ? arr[0] : undefined` becomes `arr?.[0]` */ export class PreferOptionalChain extends Recipe { name = "org.openrewrite.javascript.cleanup.prefer-optional-chain"; displayName = "Prefer optional chaining"; description = "Converts ternary expressions like `foo ? foo.bar : undefined` to use optional chaining syntax `foo?.bar`."; async editor(): Promise> { return new class extends JavaScriptVisitor { protected async visitTernary(ternary: J.Ternary, ctx: ExecutionContext): Promise { const visited = await super.visitTernary(ternary, ctx) as J.Ternary; // Check if the condition is an identifier if (visited.condition.kind !== J.Kind.Identifier) { return visited; } const conditionIdent = visited.condition as J.Identifier; const conditionName = conditionIdent.simpleName; // Check if the false part is undefined // Note: We only convert when the false part is undefined, not null, // because optional chaining returns undefined (not null) when the target is nullish. const falsePart = visited.falsePart.element; const isUndefinedFalse = falsePart.kind === J.Kind.Identifier && (falsePart as J.Identifier).simpleName === 'undefined'; if (!isUndefinedFalse) { return visited; } // Check if the true part accesses a property/method on the condition const truePart = visited.truePart.element; const result = this.extractOptionalChainTarget(truePart, conditionName); if (!result) { return visited; } // Transform to optional chaining // The result already has the Optional marker added, just update prefix return { ...result, prefix: visited.prefix } as Expression; } /** * Extracts the target expression for optional chaining if the expression * accesses a property on the given identifier. * * Returns the expression with Optional marker added, or undefined if not applicable. */ private extractOptionalChainTarget(expr: Expression, targetName: string): Expression | undefined { // Handle FieldAccess: foo.bar if (expr.kind === J.Kind.FieldAccess) { const fieldAccess = expr as J.FieldAccess; if (fieldAccess.target.kind === J.Kind.Identifier) { const target = fieldAccess.target as J.Identifier; if (target.simpleName === targetName) { // Already has optional marker? if (findMarker(target, JS.Markers.Optional)) { return fieldAccess; } // Add Optional marker to the target (foo?.bar means marker on foo) const optionalMarker: Optional = { kind: JS.Markers.Optional, id: randomId(), prefix: emptySpace }; return { ...fieldAccess, target: { ...target, markers: markers( ...target.markers.markers, optionalMarker ) } } as J.FieldAccess; } } } // Handle MethodInvocation: foo.bar() if (expr.kind === J.Kind.MethodInvocation) { const methodInvocation = expr as J.MethodInvocation; if (methodInvocation.select?.element.kind === J.Kind.Identifier) { const select = methodInvocation.select.element as J.Identifier; if (select.simpleName === targetName) { // Already has optional marker? if (findMarker(select, JS.Markers.Optional)) { return methodInvocation; } // Add Optional marker to the select const optionalMarker: Optional = { kind: JS.Markers.Optional, id: randomId(), prefix: emptySpace }; return { ...methodInvocation, select: { ...methodInvocation.select, element: { ...select, markers: markers( ...select.markers.markers, optionalMarker ) } } } as J.MethodInvocation; } } } // Handle ArrayAccess: foo[0] if (expr.kind === J.Kind.ArrayAccess) { const arrayAccess = expr as J.ArrayAccess; if (arrayAccess.indexed.kind === J.Kind.Identifier) { const indexed = arrayAccess.indexed as J.Identifier; if (indexed.simpleName === targetName) { // Already has optional marker? if (findMarker(indexed, JS.Markers.Optional)) { return arrayAccess; } // Add Optional marker to the indexed expression const optionalMarker: Optional = { kind: JS.Markers.Optional, id: randomId(), prefix: emptySpace }; return { ...arrayAccess, indexed: { ...indexed, markers: markers( ...indexed.markers.markers, optionalMarker ) } } as J.ArrayAccess; } } } return undefined; } }; } }