/*
* 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;
}
};
}
}