Skip to content
Merged
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
667 changes: 445 additions & 222 deletions rewrite-javascript/rewrite/src/javascript/add-import.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,14 @@ export class ChangeImport extends Recipe {
alias: aliasToUse,
onlyIfReferenced: false
});
} else if (aliasToUse && aliasToUse !== newMember) {
maybeAddImport(this, {
module: newModule,
member: newMember,
alias: aliasToUse,
onlyIfReferenced: false
});
} else {
maybeAddImport(this, {
module: newModule,
member: newMember,
// A moved binding keeps the local name it had. Pinning it also tells
// `maybeAddImport` not to deconflict against the import being replaced,
// which is still present in the tree it reads.
alias: aliasToUse ?? newMember,
onlyIfReferenced: false
});
}
Expand Down
129 changes: 129 additions & 0 deletions rewrite-javascript/rewrite/src/javascript/templating/bindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* 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 {J, Type} from '../../java';
import {JavaScriptVisitor} from '../visitor';
import {Cursor} from '../../tree';
import {ModuleBinding} from './types';

/**
* Whether the template's dependencies bring a workspace that could resolve `module`.
*/
export function isResolvable(module: string, dependencies: Record<string, string>): boolean {
const segments = module.split('/');
const pkg = module.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
return Object.prototype.hasOwnProperty.call(dependencies, pkg);
}

/**
* What a declared binding's name is parsed against, ahead of the template and so out of its output.
* An import is what carries attribution, and costs a module resolution to get it; a declaration
* names the binding for a module no workspace could have resolved anyway.
*/
export function bindingContextStatement(name: string, binding: ModuleBinding, dependencies: Record<string, string>): string {
if (!isResolvable(binding.module, dependencies)) {
return binding.typeOnly ? `type ${name} = any;` : `declare const ${name}: any;`;
}
const type = binding.typeOnly ? 'type ' : '';
if (binding.member === '*') {
return `import ${type}* as ${name} from '${binding.module}';`;
}
if (binding.member === undefined || binding.member === 'default') {
return `import ${type}${name} from '${binding.module}';`;
}
const specifier = binding.member === name ? name : `${binding.member} as ${name}`;
return `import ${type}{${specifier}} from '${binding.module}';`;
}

/**
* Renames the identifiers a template uses for its declared bindings to the names the file
* actually binds. Runs before parameter substitution, so only the template's own code is in
* scope and a caller's captured code is never rewritten.
*/
export async function renameBindings<T extends J>(tree: T, renames: Record<string, string>, modules: Record<string, string>): Promise<T> {
return new RenameBindingsVisitor(renames, modules).visit(tree, undefined) as Promise<T>;
}

class RenameBindingsVisitor extends JavaScriptVisitor<undefined> {
constructor(private readonly renames: Record<string, string>,
private readonly modules: Record<string, string>) {
super();
}

override async visitIdentifier(identifier: J.Identifier, p: undefined): Promise<J | undefined> {
const renamed = this.renames[identifier.simpleName];
if (renamed === undefined || renamed === identifier.simpleName) {
return identifier;
}

// Attribution settles it where the context import resolved. It is absent for a module the
// parse could not reach, and the identifier's position decides instead.
const resolved = resolvedModule(identifier);
const refersToBinding = resolved !== undefined
? resolved === this.modules[identifier.simpleName]
: !namesItsParent(this.cursor, identifier);

return refersToBinding ? {...identifier, simpleName: renamed} as J.Identifier : identifier;
}
}

/** Whether the parent is naming this identifier — as a property, a method, a declaration — rather than referencing it. */
function namesItsParent(cursor: Cursor, identifier: J.Identifier): boolean {
let c: Cursor | undefined = cursor.parent;
while (c && isPadding(c.value)) {
c = c.parent;
}
const parent = c?.value as { kind?: string; name?: unknown; select?: unknown } | undefined;

// A call names a member of whatever it selects from. With nothing selected there is no member,
// and its `name` is a reference to the function being called.
if (parent?.kind === J.Kind.MethodInvocation && !parent.select) {
return false;
}

const name = parent?.name;
return name === identifier || (name as { element?: unknown } | undefined)?.element === identifier;
}

function isPadding(value: unknown): boolean {
const kind = (value as { kind?: string } | undefined)?.kind;
return kind === J.Kind.RightPadded || kind === J.Kind.LeftPadded || kind === J.Kind.Container;
}

/** The module an identifier's attribution traces back to, following the owning-class chain to its root. */
function resolvedModule(identifier: J.Identifier): string | undefined {
const fieldType = identifier.fieldType;
if (fieldType?.kind === Type.Kind.Variable) {
const owner = (fieldType as Type.Variable).owner;
return owner && Type.isClass(owner) ? rootName(owner as Type.Class) : undefined;
}
const type = identifier.type;
if (type && Type.isMethod(type)) {
const declaring = (type as Type.Method).declaringType;
return declaring ? rootName(declaring as Type.Class) : undefined;
}
if (type && Type.isClass(type)) {
return rootName(type as Type.Class);
}
return undefined;
}

function rootName(classType: Type.Class): string {
let current: Type.Class = classType;
while (current.owningClass && Type.isClass(current.owningClass)) {
current = current.owningClass as Type.Class;
}
return Type.FullyQualified.getFullyQualifiedName(current);
}
13 changes: 11 additions & 2 deletions rewrite-javascript/rewrite/src/javascript/templating/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {PlaceholderReplacementVisitor} from './placeholder-replacement';
import {maybeParenthesize, parenthesize, requiredPrecedence, startsWithDeclarationToken} from './precedence';
import {JavaCoordinates} from './template';
import {maybeAutoFormat} from '../format';
import {renameBindings} from './bindings';
import {isExpression, isStatement} from '../parser-utils';
import {randomId} from '../../uuid';
import ts from "typescript";
Expand Down Expand Up @@ -257,6 +258,8 @@ export class TemplateEngine {
* @param values Map of capture names to values to replace the parameters with
* @param wrappersMap Map of capture names to J.RightPadded wrappers (for preserving markers)
* @param format Whether to fit the result to where it lands
* @param renames Local names for the template's declared bindings, keyed as declared
* @param modules The module each declared binding names, keyed as declared
* @returns A Promise resolving to the generated AST node
*/
static async applyTemplateFromAst(
Expand All @@ -266,7 +269,9 @@ export class TemplateEngine {
coordinates: JavaCoordinates,
values: Pick<Map<string, J>, 'get'> = new Map(),
wrappersMap: Pick<Map<string, J.RightPadded<J> | J.RightPadded<J>[]>, 'get'> = new Map(),
format: boolean = true
format: boolean = true,
renames: Record<string, string> = {},
modules: Record<string, string> = {}
): Promise<J | undefined> {
// Create substitutions map for placeholders
const substitutions = new Map<string, Parameter>();
Expand All @@ -278,9 +283,13 @@ export class TemplateEngine {
// Before substitution, so that ids carried over from the source tree survive this pass
const fresh = await randomizeIds(ast);

const bound = Object.keys(renames).length > 0
? await renameBindings(fresh.tree as J, renames, modules)
: fresh.tree;

// Unsubstitute placeholders with actual parameter values and match results
const visitor = new PlaceholderReplacementVisitor(substitutions, values, wrappersMap);
const unsubstitutedAst = (await visitor.visit(fresh.tree, null))!;
const unsubstitutedAst = (await visitor.visit(bound, null))!;

// An id may only be kept where the node answering to it is leaving the tree, which is the
// subtree this application replaces. A parameter named twice, or spliced in from somewhere
Expand Down
2 changes: 2 additions & 0 deletions rewrite-javascript/rewrite/src/javascript/templating/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export type {
MatchOptions,
TemplateParameter,
TemplateOptions,
ModuleBinding,
RewriteRule,
TryOnOptions,
RewriteConfig,
DebugOptions,
DebugLogEntry,
Expand Down
26 changes: 12 additions & 14 deletions rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/
import {Cursor, ExecutionContext, Recipe, TreeVisitor} from '../..';
import {J, Statement} from '../../java';
import {PostMatchContext, PreMatchContext, RewriteConfig, RewriteRule} from './types';
import {PostMatchContext, PreMatchContext, RewriteConfig, RewriteRule, TryOnOptions} from './types';
import {MatchResult, Pattern} from './pattern';
import {Template} from './template';
import {JavaScriptVisitor} from '../visitor';
Expand All @@ -33,7 +33,7 @@ class RewriteRuleImpl implements RewriteRule {
) {
}

async tryOn(cursor: Cursor, node: J): Promise<J | undefined> {
async tryOn(cursor: Cursor, node: J, options?: TryOnOptions): Promise<J | undefined> {
// Evaluate preMatch before attempting any pattern matching
if (this.preMatch) {
const preMatchResult = await this.preMatch(node, { cursor });
Expand All @@ -57,12 +57,10 @@ class RewriteRuleImpl implements RewriteRule {
// Apply transformation
let result: J | undefined;

const options = { values: match, format: this.format };
if (typeof this.after === 'function') {
result = await this.after(match).apply(node, cursor, options);
} else {
result = await this.after.apply(node, cursor, options);
}
const template = typeof this.after === 'function' ? this.after(match) : this.after;
const bindings = options?.bindings ?? (options?.visitor && template.resolveBindings(options.visitor));
result = await template.apply(node, cursor,
{ values: match, format: this.format, bindings: bindings || undefined });

if (result) {
return result;
Expand All @@ -83,10 +81,10 @@ class RewriteRuleImpl implements RewriteRule {
super([], () => undefined as unknown as Template);
}

async tryOn(cursor: Cursor, node: J): Promise<J | undefined> {
const firstResult = await first.tryOn(cursor, node);
async tryOn(cursor: Cursor, node: J, options?: TryOnOptions): Promise<J | undefined> {
const firstResult = await first.tryOn(cursor, node, options);
if (firstResult !== undefined) {
const secondResult = await next.tryOn(cursor, firstResult);
const secondResult = await next.tryOn(cursor, firstResult, options);
return secondResult ?? firstResult;
}
return undefined;
Expand All @@ -103,12 +101,12 @@ class RewriteRuleImpl implements RewriteRule {
super([], () => undefined as unknown as Template);
}

async tryOn(cursor: Cursor, node: J): Promise<J | undefined> {
const firstResult = await first.tryOn(cursor, node);
async tryOn(cursor: Cursor, node: J, options?: TryOnOptions): Promise<J | undefined> {
const firstResult = await first.tryOn(cursor, node, options);
if (firstResult !== undefined) {
return firstResult;
}
return await alternative.tryOn(cursor, node);
return await alternative.tryOn(cursor, node, options);
}
})();
}
Expand Down
44 changes: 42 additions & 2 deletions rewrite-javascript/rewrite/src/javascript/templating/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
import {Cursor, Tree} from '../..';
import {J} from '../../java';
import {ApplyOptions, Parameter, TemplateOptions, TemplateParameter} from './types';
import {bindingContextStatement, isResolvable} from './bindings';
import {maybeAddImport} from '../add-import';
import {JavaScriptVisitor} from '../visitor';
import {MatchResult} from './pattern';
import {generateCacheKey, globalAstCache, WRAPPERS_MAP_SYMBOL} from './utils';
import {CAPTURE_NAME_SYMBOL, RAW_CODE_SYMBOL} from './capture';
Expand Down Expand Up @@ -246,7 +249,11 @@ export class Template {
// Generate cache key for global lookup
// For raw() parameters, we need to include their code values in the key
// since they're spliced at construction time, not application time
const contextStatements = this.options.context || this.options.imports || [];
const contextStatements = [
...(this.options.context || this.options.imports || []),
...Object.entries(this.options.bindings ?? {})
.map(([name, b]) => bindingContextStatement(name, b, this.options.dependencies ?? {}))
];
const parametersKey = this.parameters.map((p, i) => {
const value = p.value;
// Include raw code values in the cache key using the symbol
Expand Down Expand Up @@ -285,6 +292,24 @@ export class Template {
return result;
}

/**
* Binds every module this template declares in the file `visitor` is traversing, and returns
* the local names to hand back through {@link ApplyOptions.bindings}. A module the dependencies
* cannot resolve is bound whether or not the template goes on to reference it, so call this
* where the template is known to apply — {@link RewriteRule.tryOn} does, once a pattern matched.
*/
resolveBindings(visitor: JavaScriptVisitor<any>): Record<string, string> {
const resolved: Record<string, string> = {};
const dependencies = this.options.dependencies ?? {};
for (const [name, binding] of Object.entries(this.options.bindings ?? {})) {
// Recognising the reference the template splices in takes attribution, which only the
// import form of a context statement carries. Without one there is nothing to look for.
const onlyIfReferenced = isResolvable(binding.module, dependencies);
resolved[name] = maybeAddImport(visitor, {...binding, preferredName: name, onlyIfReferenced});
}
return resolved;
}

/**
* Applies this template and returns the resulting tree.
*
Expand Down Expand Up @@ -347,6 +372,19 @@ export class Template {
}
}

const declared = this.options.bindings ?? {};
const renames: Record<string, string> = {};
const modules: Record<string, string> = {};
for (const [name, binding] of Object.entries(declared)) {
const bound = options?.bindings?.[name];
if (bound === undefined) {
throw new Error(`Template declares a binding for '${name}' but was applied without a local name for it. ` +
`Pass bindings: template.resolveBindings(visitor) to apply().`);
}
renames[name] = bound;
modules[name] = binding.module;
}

// Use instance-level cache to get the template tree
const ast = await this.getTemplateTree();

Expand All @@ -361,7 +399,9 @@ export class Template {
},
normalizedValues,
wrappersMap,
options?.format ?? true
options?.format ?? true,
renames,
modules
);
}
}
Expand Down
Loading