diff --git a/rewrite-javascript/rewrite/src/javascript/templating/capture.ts b/rewrite-javascript/rewrite/src/javascript/templating/capture.ts index e8f55961303..cd9935981d2 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/capture.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/capture.ts @@ -75,6 +75,8 @@ export const CAPTURE_CAPTURING_SYMBOL = Symbol('captureCapturing'); export const CAPTURE_TYPE_SYMBOL = Symbol('captureType'); // Symbol to identify RawCode instances export const RAW_CODE_SYMBOL = Symbol('rawCode'); +// Symbol to identify DerivedCapture instances +export const DERIVED_CAPTURE_SYMBOL = Symbol('derivedCapture'); export class CaptureImpl implements Capture { public readonly name: string; @@ -351,6 +353,36 @@ function createCaptureProxy(impl: CaptureImpl): any { }); } +/** + * Represents a derived capture whose template substitution is computed from another capture's matched value. + * + * A derived capture is NOT used in patterns — it has no matching behavior. + * It IS used in templates — at application time, it resolves the source capture from the match result, + * passes it through the transform function, and uses the result as the substitution. + */ +export class DerivedCapture { + [DERIVED_CAPTURE_SYMBOL] = true; + [CAPTURE_NAME_SYMBOL]: string; + [CAPTURE_TYPE_SYMBOL]: string | Type | undefined; + + constructor( + public readonly source: Capture, + public readonly transform: (node: J | J[]) => RawCode | J, + options?: { type?: string | Type } + ) { + this[CAPTURE_NAME_SYMBOL] = `derived_${source.getName()}_${DerivedCapture.nextId++}`; + if (options?.type) { + this[CAPTURE_TYPE_SYMBOL] = options.type; + } + } + + getName(): string { + return this[CAPTURE_NAME_SYMBOL]; + } + + static nextId = 1; +} + // Overload 1: Options object with constraint (no variadic) export function capture( options: CaptureOptions & { variadic?: never } @@ -388,6 +420,35 @@ export function capture(nameOrOptions?: string | CaptureOptions): Ca // Static counter for generating unique IDs for unnamed captures capture.nextUnnamedId = 1; +/** + * Creates a derived capture whose template substitution is computed from another capture's matched value. + * + * A derived capture: + * - Is NOT used in patterns — it has no matching behavior + * - IS used in templates — at application time, it resolves the source capture, applies the transform, and uses the result + * + * @param source The capture whose matched value will be transformed + * @param transform Function that receives the matched node and returns a RawCode or J node for substitution + * @param options Optional configuration. Supports `type` for type attribution (same as regular captures). + * @returns A DerivedCapture that can be used in templates + * + * @example + * const unit = capture({ name: 'unit', constraint: ... }); + * const temporalUnit = capture.derived(unit, (node) => { + * const str = (node as J.Literal).value as string; + * return raw(UNIT_MAP[str]); + * }); + * // Use in template: + * template`${obj}.add({${temporalUnit}: ${amount}})` + * + * @example + * // With type attribution + * const derived = capture.derived(source, transform, { type: 'string' }); + */ +capture.derived = function(source: Capture, transform: (node: J | J[]) => RawCode | J, options?: { type?: string | Type }): DerivedCapture { + return new DerivedCapture(source, transform, options); +}; + /** * Creates a non-capturing pattern match for use in patterns. * diff --git a/rewrite-javascript/rewrite/src/javascript/templating/engine.ts b/rewrite-javascript/rewrite/src/javascript/templating/engine.ts index bc2d030429e..5d1b9bedbad 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/engine.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/engine.ts @@ -18,7 +18,7 @@ import {emptySpace, J, Statement, Type} from '../../java'; import {Any, Capture, JavaScriptParser, JavaScriptVisitor, JS} from '..'; import {create as produce} from 'mutative'; import {CaptureMarker, PlaceholderUtils, WRAPPER_FUNCTION_NAME} from './utils'; -import {CAPTURE_NAME_SYMBOL, CAPTURE_TYPE_SYMBOL, CaptureImpl, CaptureValue, RAW_CODE_SYMBOL, RawCode} from './capture'; +import {CAPTURE_NAME_SYMBOL, CAPTURE_TYPE_SYMBOL, CaptureImpl, CaptureValue, DerivedCapture, DERIVED_CAPTURE_SYMBOL, RAW_CODE_SYMBOL, RawCode} from './capture'; import {PlaceholderReplacementVisitor} from './placeholder-replacement'; import {JavaCoordinates} from './template'; import {maybeAutoFormat} from '../format'; @@ -281,6 +281,21 @@ export class TemplateEngine { const param = parameters[i].value; const placeholder = `${PlaceholderUtils.PLACEHOLDER_PREFIX}${i}__`; + // DerivedCapture — generate type preamble only if it has a type annotation + if (param instanceof DerivedCapture || + (param && typeof param === 'object' && param[DERIVED_CAPTURE_SYMBOL])) { + const captureType = param[CAPTURE_TYPE_SYMBOL]; + if (captureType) { + const typeString = typeof captureType === 'string' + ? captureType + : this.typeToString(captureType); + if (typeString !== 'any') { + preamble.push(`let ${placeholder}: ${typeString};`); + } + } + continue; + } + // Check for Capture (could be a Proxy, so check for symbol property) const isCapture = param instanceof CaptureImpl || (param && typeof param === 'object' && param[CAPTURE_NAME_SYMBOL]); diff --git a/rewrite-javascript/rewrite/src/javascript/templating/index.ts b/rewrite-javascript/rewrite/src/javascript/templating/index.ts index d0db0c15d6e..f6d4a6d620b 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/index.ts @@ -39,6 +39,7 @@ export { or, not, capture, + DerivedCapture, any, param, raw, diff --git a/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts b/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts index ea521c4bb49..7ff74d218c5 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts @@ -26,7 +26,7 @@ import { MatchResult as IMatchResult, PatternOptions } from './types'; -import {CAPTURE_CAPTURING_SYMBOL, CAPTURE_NAME_SYMBOL, CaptureImpl, RAW_CODE_SYMBOL, RawCode} from './capture'; +import {CAPTURE_CAPTURING_SYMBOL, CAPTURE_NAME_SYMBOL, CaptureImpl, DerivedCapture, DERIVED_CAPTURE_SYMBOL, RAW_CODE_SYMBOL, RawCode} from './capture'; import {DebugPatternMatchingComparator, MatcherCallbacks, MatcherState, PatternMatchingComparator} from './comparator'; import {CaptureMarker, CaptureStorageValue, generateCacheKey, globalAstCache, WRAPPERS_MAP_SYMBOL} from './utils'; import {TemplateEngine} from './engine'; @@ -1090,6 +1090,14 @@ function createPattern( captures: (Capture | Any | RawCode | string)[], options: PatternOptions ): Pattern { + // Validate that no DerivedCapture is used in a pattern + for (const c of captures) { + if (c instanceof DerivedCapture || + (c && typeof c === 'object' && (c as any)[DERIVED_CAPTURE_SYMBOL])) { + throw new Error('DerivedCapture cannot be used in patterns. Derived captures are only valid in templates.'); + } + } + const capturesByName = captures.reduce((map, c) => { // Skip raw code - it's not a capture if (c instanceof RawCode || (typeof c === 'object' && c && (c as any)[RAW_CODE_SYMBOL])) { diff --git a/rewrite-javascript/rewrite/src/javascript/templating/placeholder-replacement.ts b/rewrite-javascript/rewrite/src/javascript/templating/placeholder-replacement.ts index fc836a6567f..33097ee5150 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/placeholder-replacement.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/placeholder-replacement.ts @@ -19,7 +19,7 @@ import {JS} from '..'; import {JavaScriptVisitor} from '../visitor'; import {create as produce} from 'mutative'; import {PlaceholderUtils} from './utils'; -import {CaptureImpl, TemplateParamImpl, CaptureValue, CAPTURE_NAME_SYMBOL} from './capture'; +import {CaptureImpl, TemplateParamImpl, CaptureValue, DerivedCapture, CAPTURE_NAME_SYMBOL, DERIVED_CAPTURE_SYMBOL, RawCode, RAW_CODE_SYMBOL} from './capture'; import {Parameter} from './types'; /** @@ -396,6 +396,35 @@ export class PlaceholderReplacementVisitor extends JavaScriptVisitor { return placeholder; } + // Check if the parameter value is a DerivedCapture + const isDerivedCapture = param.value instanceof DerivedCapture || + (param.value && typeof param.value === 'object' && param.value[DERIVED_CAPTURE_SYMBOL]); + + if (isDerivedCapture) { + const derived = param.value as DerivedCapture; + const sourceName = derived.source.getName(); + const sourceNode = this.values.get(sourceName); + if (sourceNode !== undefined) { + const transformed = derived.transform(sourceNode as J | J[]); + // If transform returns RawCode, create an Identifier from the code string + if (transformed instanceof RawCode || (transformed && typeof transformed === 'object' && (transformed as any)[RAW_CODE_SYMBOL])) { + const rawCode = transformed as RawCode; + return produce(placeholder as J.Identifier, draft => { + draft.simpleName = rawCode.code; + draft.prefix = placeholder.prefix; + }); + } + // Otherwise it's a J node — use it directly + if (isTree(transformed)) { + return produce(transformed as J, draft => { + draft.markers = placeholder.markers; + draft.prefix = this.mergePrefix((transformed as J).prefix, placeholder.prefix); + }); + } + } + return placeholder; + } + // Check if the parameter value is a Capture (could be a Proxy) or TemplateParam const isCapture = param.value instanceof CaptureImpl || (param.value && typeof param.value === 'object' && param.value[CAPTURE_NAME_SYMBOL]); diff --git a/rewrite-javascript/rewrite/src/javascript/templating/types.ts b/rewrite-javascript/rewrite/src/javascript/templating/types.ts index a008f95d154..74f43d5da2e 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/types.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/types.ts @@ -17,7 +17,7 @@ import {Cursor, Tree} from '../..'; import {J, Type} from '../../java'; import type {Pattern} from "./pattern"; import type {Template} from "./template"; -import type {CaptureValue, RawCode} from "./capture"; +import type {CaptureValue, DerivedCapture, RawCode} from "./capture"; /** * Options for variadic captures that match zero or more nodes in a sequence. @@ -420,6 +420,7 @@ export interface MatchOptions { export type TemplateParameter = Capture | CaptureValue + | DerivedCapture | TemplateParam | RawCode | Tree diff --git a/rewrite-javascript/rewrite/test/javascript/templating/capture-derived.test.ts b/rewrite-javascript/rewrite/test/javascript/templating/capture-derived.test.ts new file mode 100644 index 00000000000..dde65d3222e --- /dev/null +++ b/rewrite-javascript/rewrite/test/javascript/templating/capture-derived.test.ts @@ -0,0 +1,177 @@ +/* + * 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 {fromVisitor, RecipeSpec} from "../../../src/test"; +import {capture, JavaScriptVisitor, pattern, raw, rewrite, template, typescript} from "../../../src/javascript"; +import {J} from "../../../src/java"; + +describe('capture.derived', () => { + test('derived capture with raw() output maps a captured value', () => { + const UNIT_MAP: Record = { + 'years': 'years', + 'months': 'months', + 'days': 'days', + }; + + const obj = capture('obj'); + const amount = capture('amount'); + const unit = capture({ + name: 'unit', + constraint: (n: any) => n.kind === J.Kind.Literal && typeof n.value === 'string' && UNIT_MAP[n.value] !== undefined + }); + const temporalUnit = capture.derived(unit, (node) => { + const str = (node as J.Literal).value as string; + return raw(UNIT_MAP[str]); + }); + + const rule = rewrite(() => ({ + before: pattern`${obj}.add(${amount}, ${unit})`, + after: template`${obj}.add({${temporalUnit}: ${amount}})`, + })); + + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise { + return await rule.tryOn(this.cursor, method) || method; + } + }); + + return spec.rewriteRun( + //language=typescript + typescript( + 'const result = date.add(5, "years")', + 'const result = date.add({years: 5})' + ), + ); + }); + + test('derived capture with J node output', () => { + const expr = capture('expr'); + const negated = capture.derived(expr, (node) => { + return node as J; + }); + + const rule = rewrite(() => ({ + before: pattern`negate(${expr})`, + after: template`${negated}`, + })); + + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise { + return await rule.tryOn(this.cursor, method) || method; + } + }); + + return spec.rewriteRun( + //language=typescript + typescript( + 'const x = negate(foo)', + 'const x = foo' + ), + ); + }); + + test('derived capture in before pattern throws', () => { + const unit = capture('unit'); + const derived = capture.derived(unit, (node) => raw('mapped')); + + expect(() => { + pattern`foo(${derived as any})`; + }).toThrow(); + }); + + test('derived capture in rewrite rule eliminates dynamic after', () => { + // Uses different capture names to avoid pattern cache collision with other tests + const UNIT_MAP: Record = { + 'year': 'years', + 'month': 'months', + 'week': 'weeks', + 'day': 'days', + 'hour': 'hours', + 'minute': 'minutes', + 'second': 'seconds', + }; + + const target = capture('target'); + const val = capture('val'); + const unitArg = capture({ + name: 'unitArg', + constraint: (n: any) => n.kind === J.Kind.Literal && typeof n.value === 'string' && UNIT_MAP[n.value] !== undefined + }); + const temporalProp = capture.derived(unitArg, (node) => { + const str = (node as J.Literal).value as string; + return raw(UNIT_MAP[str]); + }); + + const rule = rewrite(() => ({ + before: pattern`${target}.add(${val}, ${unitArg})`, + after: template`${target}.add({${temporalProp}: ${val}})`, + })); + + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise { + return await rule.tryOn(this.cursor, method) || method; + } + }); + + return spec.rewriteRun( + //language=typescript + typescript( + 'const x = m.add(1, "month")', + 'const x = m.add({months: 1})' + ), + ); + }); + + test('derived capture transforms different matched values correctly', () => { + const SUFFIX_MAP: Record = { + 'ms': 'milliseconds', + 's': 'seconds', + 'm': 'minutes', + }; + + const value = capture('value'); + const unit = capture({ + name: 'unit', + constraint: (n: any) => n.kind === J.Kind.Literal && typeof n.value === 'string' && SUFFIX_MAP[n.value] !== undefined + }); + const fullUnit = capture.derived(unit, (node) => { + const str = (node as J.Literal).value as string; + return raw(SUFFIX_MAP[str]); + }); + + const rule = rewrite(() => ({ + before: pattern`duration(${value}, ${unit})`, + after: template`Temporal.Duration.from({${fullUnit}: ${value}})`, + })); + + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise { + return await rule.tryOn(this.cursor, method) || method; + } + }); + + return spec.rewriteRun( + //language=typescript + typescript( + 'const t = duration(100, "ms")', + 'const t = Temporal.Duration.from({milliseconds: 100})' + ), + ); + }); +});