Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions rewrite-javascript/rewrite/src/javascript/templating/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = any> implements Capture<T> {
public readonly name: string;
Expand Down Expand Up @@ -351,6 +353,36 @@ function createCaptureProxy<T>(impl: CaptureImpl<T>): 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<T = any>(
options: CaptureOptions<T> & { variadic?: never }
Expand Down Expand Up @@ -388,6 +420,35 @@ export function capture<T = any>(nameOrOptions?: string | CaptureOptions<T>): 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.
*
Expand Down
17 changes: 16 additions & 1 deletion rewrite-javascript/rewrite/src/javascript/templating/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
or,
not,
capture,
DerivedCapture,
any,
param,
raw,
Expand Down
10 changes: 9 additions & 1 deletion rewrite-javascript/rewrite/src/javascript/templating/pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1090,6 +1090,14 @@ function createPattern(
captures: (Capture | Any<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])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -396,6 +396,35 @@ export class PlaceholderReplacementVisitor extends JavaScriptVisitor<any> {
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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -420,6 +420,7 @@ export interface MatchOptions {
export type TemplateParameter =
Capture
| CaptureValue
| DerivedCapture
| TemplateParam
| RawCode
| Tree
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*
* Copyright 2025 the original author or authors.
* <p>
* 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
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* 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<string, string> = {
'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<any> {
override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise<J | undefined> {
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<any> {
override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise<J | undefined> {
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<string, string> = {
'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<any> {
override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise<J | undefined> {
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<string, string> = {
'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<any> {
override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise<J | undefined> {
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})'
),
);
});
});
Loading