diff --git a/rewrite-javascript/rewrite/CLAUDE.md b/rewrite-javascript/rewrite/CLAUDE.md index 53ad18f681..011e1c406c 100644 --- a/rewrite-javascript/rewrite/CLAUDE.md +++ b/rewrite-javascript/rewrite/CLAUDE.md @@ -209,6 +209,37 @@ before every test. A consumer of the published package opts in by calling `RecipeSpec.trackSuiteConfiguration()` from its own vitest `setupFiles`; without that call no spec is registered and nothing is reset. +## JavaScript module bindings + +`maybeBind`, `maybeUnbind` and `maybeRebind` (`src/javascript/binding.ts`) give a recipe a local +name for a module whether the file uses ES imports, `require`, or an AMD `define` block. The lane +is decided from the cursor, so one call serves all three. + +AMD pairs the dependency array with the factory parameter list *by position*, and a parameter can +only be appended at the end. Nothing in the LST enforces that pairing, and a mistake parses and +prints plausibly while binding later modules to the wrong names — so every edit keeps the two +lists index-aligned, and an operation that cannot is refused rather than guessed. + +### When `maybeBind` returns `undefined` + +- the block's dependency and parameter counts already disagree, in either direction +- a `member` is requested on the AMD lane, which binds whole modules only +- the file binds its modules with `require` and one would have to be created (`ImportStyle.CommonJS` + has no add path); reuse of an existing `require` is always allowed +- no legal identifier can be derived from the module's last path segment and no `preferredName` + or `alias` was given — `lodash-es`, `node:fs`, `@scope/my-lib`, `a/class` +- a pinned `alias` cannot be bound verbatim, since deconflicting it would leave code the caller + already emitted unbound +- there is no reachable compilation unit + +### When `maybeRebind` returns `undefined` + +The four above that still apply, plus: nothing binds `from`; `from` or `to` names a member on the +AMD lane; or the two differ in default/namespace/named shape while `from`'s statement binds nothing +else. That last one is a layering boundary rather than an oversight — the only edit available in +place is a rewrite of the existing clause, and changing shape needs whole-statement replacement +with the header-preserving prefix transfer that `RemoveImport` does over the statement list. + ## RPC Sender/Receiver Each language module has `rpc.ts` with a Sender (visit tree → serialize to queue) and Receiver (read queue → reconstruct tree). These must stay aligned with each other AND with the Java equivalents. Any mismatch causes deadlocks or corrupted trees. diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index 3acfa297eb..27d79064e9 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -1,11 +1,11 @@ import {JavaScriptVisitor} from "./visitor"; -import {emptySpace, J, rightPadded, singleSpace, space, Statement, Type} from "../java"; +import {ElementRemovalFormatter, emptySpace, isIdentifier, J, rightPadded, singleSpace, space, Statement, Type} from "../java"; import {JS, JSX} from "./tree"; import {randomId} from "../uuid"; import {emptyMarkers, markers} from "../markers"; import {getStyle, PrettierStyle, SpacesStyle, StyleKind} from "./style"; -import {Cursor} from "../tree"; -import {bindingNames, namesDeclaredIn} from "./scope"; +import {bindingNames, compilationUnitOf, cursorOf, declarationsOf, deconflict, namesDeclaredIn} from "./scope"; +import {create as produce, Draft} from "mutative"; export type QuoteChar = "'" | '"'; @@ -66,49 +66,15 @@ export interface AddImportOptions { } /** - * Register an AddImport visitor to add an import statement to a JavaScript/TypeScript file - * @param visitor The visitor to add the import addition to - * @param options Configuration options for the import to add - * @returns The local name the module is bound to: an existing binding's where one answers this - * request, otherwise the name the new import will use, suffixed if the file already binds it. `onlyIfReferenced` defaults to true, so - * the import may never appear, and the name is then what it would have gone by. A side-effect - * import binds no name and returns `undefined`. - * - * @example - * // Add a named import - * maybeAddImport(visitor, { module: 'fs', member: 'readFile' }); - * - * @example - * // Add a default import using the 'default' member specifier - * maybeAddImport(visitor, { module: 'react', member: 'default', alias: 'React' }); - * - * @example - * // Add a default import (legacy way, without specifying member) - * maybeAddImport(visitor, { module: 'react', alias: 'React' }); - * - * @example - * // Add a namespace import - * maybeAddImport(visitor, { module: 'crypto', member: '*', alias: 'crypto' }); - * - * @example - * // Add a side-effect import - * maybeAddImport(visitor, { module: 'core-js/stable', sideEffectOnly: true }); + * Ensures an import for `options.module` exists, queuing an `AddImport` edit where none already + * serves the request. `maybeBind`'s ESM/CommonJS lane is this function; its own JSDoc carries the + * return-value contract. `refuseCreate` answers `undefined` instead of queuing a new import, + * without affecting whether an existing binding answers the request first. */ -export function maybeAddImport( +export function bindImport( visitor: JavaScriptVisitor, - options: AddImportOptions & { sideEffectOnly: true } -): undefined; -export function maybeAddImport( - visitor: JavaScriptVisitor, - options: AddImportOptions & { sideEffectOnly?: false } -): string; -export function maybeAddImport( - visitor: JavaScriptVisitor, - options: AddImportOptions -): string | undefined; -export function maybeAddImport( - visitor: JavaScriptVisitor, - options: AddImportOptions + options: AddImportOptions, + refuseCreate?: boolean ): string | undefined { validate(options); const module = moduleNameOf(options.module); @@ -133,12 +99,18 @@ export function maybeAddImport( } if (sideEffectOnly) { + if (refuseCreate) { + return undefined; + } visitor.afterVisit.push(new AddImport(options)); return undefined; } // A pinned alias is the whole answer, so the file has no say and nothing below needs to read it. if (options.alias) { + if (refuseCreate) { + return undefined; + } visitor.afterVisit.push(new AddImport(options, options.alias)); return options.alias; } @@ -147,6 +119,9 @@ export function maybeAddImport( const cursor = cursorOf(visitor); const cu = cursor && compilationUnitOf(cursor); if (!cu) { + if (refuseCreate) { + return undefined; + } visitor.afterVisit.push(new AddImport(options, derived)); return derived; } @@ -164,10 +139,15 @@ export function maybeAddImport( } } + if (refuseCreate) { + return undefined; + } + // Only the module scope answers for a name, but any scope in the file occupies one. The queue // gives every later request for this module the name chosen here, so it has to clear the scopes // those references will sit in, which are not known yet. - const name = deconflict(derived, takenNames(namesDeclaredIn(cu), visitor)); + const taken = takenNames(namesDeclaredIn(cu), visitor); + const name = deconflict(derived, candidate => taken.has(candidate)); visitor.afterVisit.push(new AddImport(options, name)); return name; } @@ -219,12 +199,12 @@ function derivedName(options: AddImportOptions): string { } /** `'default'` and an absent member both name a default import, which binds no member name of its own. */ -function memberName(member: string | undefined): string | undefined { +export function memberName(member: string | undefined): string | undefined { return member === 'default' ? undefined : member; } /** A name introduced into the file's module scope, and where it came from when that is an import. */ -interface ModuleScopeBinding { +export interface ModuleScopeBinding { name: string; module?: string; /** Carries the {@link AddImportOptions.member} spelling, so `undefined` means a default import. */ @@ -232,18 +212,8 @@ interface ModuleScopeBinding { typeOnly?: boolean; } -function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { - // `cursor` is protected on `TreeVisitor`, and the `maybeAddImport`/`maybeRemoveImport` - // API is free functions, so reaching it takes a cast. - return (visitor as unknown as { cursor?: Cursor }).cursor; -} - -function compilationUnitOf(cursor: Cursor): JS.CompilationUnit | undefined { - return cursor.firstEnclosing((v): v is JS.CompilationUnit => v?.kind === JS.Kind.CompilationUnit); -} - /** What the file's imports and `require`s bind at module scope, and the module each name comes from. */ -function moduleScopeBindings(cu: JS.CompilationUnit): ModuleScopeBinding[] { +export function moduleScopeBindings(cu: JS.CompilationUnit): ModuleScopeBinding[] { const bindings: ModuleScopeBinding[] = []; const declaredByVariables = (varDecl: J.VariableDeclarations): void => { @@ -257,20 +227,12 @@ function moduleScopeBindings(cu: JS.CompilationUnit): ModuleScopeBinding[] { for (const stmt of cu.statements) { const statement = stmt.element; - switch (statement?.kind) { - case JS.Kind.Import: - bindings.push(...importBindings(statement as JS.Import)); - break; - case J.Kind.VariableDeclarations: - declaredByVariables(statement as J.VariableDeclarations); - break; - case JS.Kind.ScopedVariableDeclarations: - for (const variable of (statement as JS.ScopedVariableDeclarations).variables) { - if (variable.element?.kind === J.Kind.VariableDeclarations) { - declaredByVariables(variable.element as J.VariableDeclarations); - } - } - break; + if (statement?.kind === JS.Kind.Import) { + bindings.push(...importBindings(statement as JS.Import)); + continue; + } + for (const declaration of declarationsOf(statement)) { + declaredByVariables(declaration); } } @@ -294,7 +256,7 @@ function isRequireCall(methodInv: J.MethodInvocation): boolean { * The module a `require(...)` call loads. `obj.require('x')` selects a method rather than loading a * module, and a specifier that is not a string literal names none that can be read here. */ -function requiredModuleOf(methodInv: J.MethodInvocation): string | undefined { +export function requiredModuleOf(methodInv: J.MethodInvocation): string | undefined { if (!isRequireCall(methodInv)) { return undefined; } @@ -386,23 +348,12 @@ function takenNames(inScope: ReadonlySet, visitor: JavaScriptVisitor): string { - if (!taken.has(derived)) { - return derived; - } - let suffix = 1; - while (taken.has(`${derived}_${suffix}`)) { - suffix++; - } - return `${derived}_${suffix}`; -} - /** * The parser lifts surrogate pairs out of `valueSource` into `unicodeEscapes` and, when it does, * sets `value` to the quoted source (`parser.ts` `mapLiteral`), so neither field alone is the * module name. Reuniting them and stripping the quotes yields it for either shape of literal. */ -function moduleNameOf(module: string | J.Literal): string { +export function moduleNameOf(module: string | J.Literal): string { if (typeof module === 'string') { return module; } @@ -1595,3 +1546,295 @@ export class AddImport

extends JavaScriptVisitor

{ } + +/** + * The local name `jsImport` binds `member` of `module` to, or `undefined` where it binds + * something else. `'default'` and an absent `member` both mean the default import, matching + * {@link memberName}. + */ +function importBinds(jsImport: JS.Import, module: string, member: string | undefined): string | undefined { + const specifier = jsImport.moduleSpecifier?.element; + if (specifier?.kind !== J.Kind.Literal || (specifier as J.Literal).value !== module) { + return undefined; + } + const importClause = jsImport.importClause; + if (!importClause) { + return undefined; + } + const key = memberName(member); + if (key === undefined) { + const nameElem = importClause.name?.element; + return nameElem && isIdentifier(nameElem) ? nameElem.simpleName : undefined; + } + if (key === '*') { + const namedBindings = importClause.namedBindings; + return namedBindings?.kind === JS.Kind.Alias && isIdentifier((namedBindings as JS.Alias).alias) + ? ((namedBindings as JS.Alias).alias as J.Identifier).simpleName + : undefined; + } + const namedBindings = importClause.namedBindings; + if (namedBindings?.kind !== JS.Kind.NamedImports) { + return undefined; + } + for (const elem of (namedBindings as JS.NamedImports).elements.elements) { + const specifierNode = elem.element.specifier; + if (!namedSpecifierImports(specifierNode, key)) { + continue; + } + if (isIdentifier(specifierNode)) { + return specifierNode.simpleName; + } + const alias = (specifierNode as JS.Alias).alias; + if (isIdentifier(alias)) { + return alias.simpleName; + } + } + return undefined; +} + +/** Whether `specifier` imports the member `key`, under whatever local name it binds it to. */ +function namedSpecifierImports(specifier: JS.ImportSpecifier["specifier"], key: string): boolean { + if (isIdentifier(specifier)) { + return specifier.simpleName === key; + } + if (specifier.kind === JS.Kind.Alias) { + const propertyName = (specifier as JS.Alias).propertyName.element; + return isIdentifier(propertyName) && propertyName.simpleName === key; + } + return false; +} + +/** Whether the named specifier binding `key` carries its own inline `type`, as in `{type a, b}`. */ +function namedSpecifierIsTypeOnly(imp: JS.Import, key: string): boolean { + const namedBindings = imp.importClause?.namedBindings; + if (namedBindings?.kind !== JS.Kind.NamedImports) { + return false; + } + for (const elem of (namedBindings as JS.NamedImports).elements.elements) { + if (namedSpecifierImports(elem.element.specifier, key)) { + return elem.element.importType.element; + } + } + return false; +} + +/** How many named specifiers `jsImport` carries, `0` for a default, namespace or side-effect import. */ +function namedImportCount(jsImport: JS.Import): number { + const namedBindings = jsImport.importClause?.namedBindings; + return namedBindings?.kind === JS.Kind.NamedImports ? (namedBindings as JS.NamedImports).elements.elements.length : 0; +} + +/** + * Whether `jsImport`'s clause binds exactly one thing — a default, a namespace, or one named + * specifier. That is the only shape a module-only move can rewrite in place: whichever binding + * `importBinds` already matched is this one, so nothing else the clause carries goes along with it. + */ +function isOnlyMember(jsImport: JS.Import): boolean { + const importClause = jsImport.importClause; + if (!importClause) { + return false; + } + const hasDefault = importClause.name !== undefined; + const hasNamespace = importClause.namedBindings?.kind === JS.Kind.Alias; + return (hasDefault ? 1 : 0) + (hasNamespace ? 1 : 0) + namedImportCount(jsImport) === 1; +} + +export interface ExistingImportBinding { + localName: string; + onlyMemberOfStatement: boolean; +} + +/** + * The existing binding for `member` of `module`, read from `cu`'s own import statements — what + * `maybeRebind` reads before committing to a `RebindImport` edit. + */ +export function existingImportBinding( + cu: JS.CompilationUnit, + module: string, + member: string | undefined +): ExistingImportBinding | undefined { + for (const stmt of cu.statements) { + const element = stmt.element; + if (element?.kind !== JS.Kind.Import) { + continue; + } + const localName = importBinds(element as JS.Import, module, member); + if (localName !== undefined) { + return {localName, onlyMemberOfStatement: isOnlyMember(element as JS.Import)}; + } + } + return undefined; +} + +/** + * Binds `member` under the name `local` already carries, so the file's references to it still + * resolve. `local` itself becomes the alias, keeping the type attribution it holds, and the alias + * takes its prefix: that whitespace separates the specifier from a `type` keyword before it. + */ +function aliasing(local: J.Identifier, member: string): JS.Alias { + const propertyName: J.Identifier = { + id: randomId(), + kind: J.Kind.Identifier, + prefix: emptySpace, + markers: emptyMarkers, + annotations: [], + simpleName: member, + type: undefined, + fieldType: undefined + }; + return { + id: randomId(), + kind: JS.Kind.Alias, + prefix: local.prefix, + markers: emptyMarkers, + propertyName: rightPadded(propertyName, singleSpace), + alias: {...local, prefix: singleSpace} + }; +} + +/** + * Drops the binding for `member` from `jsImport`'s clause — the default, the namespace alias, or + * one entry of the named list, whichever `member` names — keeping everything else the clause + * binds. `ElementRemovalFormatter` carries the dropped binding's prefix onto whatever prints + * next, the same way `RemoveImport` keeps formatting sane when trimming a list. + */ +function removeBinding(jsImport: JS.Import, member: string | undefined): JS.Import { + const importClause = jsImport.importClause; + if (!importClause) { + return jsImport; + } + const key = memberName(member); + + if (key === undefined) { + if (!importClause.name) { + return jsImport; + } + const namedBindings = importClause.namedBindings; + if (namedBindings?.kind === JS.Kind.NamedImports) { + // `NamedImports` keeps the space before its own `{` on the container's `before`, + // not on its own prefix, so the removed default's prefix has to land there instead. + const namedImports = namedBindings as JS.NamedImports; + const updated: JS.NamedImports = { + ...namedImports, + elements: {...namedImports.elements, before: importClause.name.element.prefix} + }; + return {...jsImport, importClause: {...importClause, name: undefined, namedBindings: updated}}; + } + if (namedBindings) { + const formatter = new ElementRemovalFormatter(); + formatter.markRemoved(importClause.name.element); + return {...jsImport, importClause: {...importClause, name: undefined, namedBindings: formatter.processKept(namedBindings)}}; + } + return {...jsImport, importClause: {...importClause, name: undefined}}; + } + + if (key === '*') { + return {...jsImport, importClause: {...importClause, namedBindings: undefined}}; + } + + if (importClause.namedBindings?.kind !== JS.Kind.NamedImports) { + return jsImport; + } + const namedImports = importClause.namedBindings as JS.NamedImports; + const formatter = new ElementRemovalFormatter(); + const kept: J.RightPadded[] = []; + for (const entry of namedImports.elements.elements) { + if (namedSpecifierImports(entry.element.specifier, key)) { + formatter.markRemoved(entry.element); + } else { + kept.push({...entry, element: formatter.processKept(entry.element)}); + } + } + if (kept.length === 0) { + // An emptied brace list still prints, as `import D, {} from "m"`, so it goes with its + // last member; the caller drops the whole statement when no default remains either. + return {...jsImport, importClause: {...importClause, namedBindings: undefined}}; + } + const updatedNamedImports: JS.NamedImports = {...namedImports, elements: {...namedImports.elements, elements: kept}}; + return {...jsImport, importClause: {...importClause, namedBindings: updatedNamedImports}}; +} + +/** + * Moves the binding `from` names to `to`, keeping the local name it already had. In place when + * the statement that carries it binds nothing else — module and member specifier rewritten there + * directly; otherwise the old specifier drops and {@link bindImport} queues the replacement, + * aliased to the preserved name. + * + * Not built on `RemoveImport`/`maybeUnbind`: those only drop a binding once nothing references + * it, but a rebind moves one that is still in use — removal here has to be unconditional. + */ +export class RebindImport

extends JavaScriptVisitor

{ + constructor( + readonly from: {module: string; member?: string}, + readonly to: {module: string; member?: string}, + readonly localName: string + ) { + super(); + } + + private transformedInPlace = false; + private typeOnly = false; + + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: P): Promise { + const visited = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit; + if (!this.transformedInPlace) { + bindImport(this, { + module: this.to.module, + member: this.to.member, + alias: this.localName, + typeOnly: this.typeOnly, + onlyIfReferenced: false + }); + } + return visited; + } + + override async visitImportDeclaration(jsImport: JS.Import, p: P): Promise { + const imp = await super.visitImportDeclaration(jsImport, p) as JS.Import; + + const key = memberName(this.from.member); + if (importBinds(imp, this.from.module, this.from.member) === undefined) { + return imp; + } + // A moved named specifier's own inline `type` marks it type-only even where the clause + // it's leaving is not — the replacement needs the same answer to stay type-safe. + this.typeOnly = (imp.importClause?.typeOnly ?? false) || + (key !== undefined && key !== '*' && namedSpecifierIsTypeOnly(imp, key)); + + if (!isOnlyMember(imp)) { + return removeBinding(imp, this.from.member); + } + + this.transformedInPlace = true; + return produce(imp, draft => { + const literal = draft.moduleSpecifier!.element as Draft; + literal.value = this.to.module; + const originalSource = literal.valueSource || `"${this.from.module}"`; + const quoteChar = originalSource.startsWith("'") ? "'" : '"'; + literal.valueSource = `${quoteChar}${this.to.module}${quoteChar}`; + + // A named specifier's local name has to stay put in the source, since it is what the + // rest of the file already reads; default and namespace imports carry that name on + // the clause itself, which needs no edit for a module-only move. + const toKey = memberName(this.to.member); + if (key !== undefined && key !== '*' && toKey !== undefined && toKey !== key) { + const importClause = draft.importClause; + if (importClause?.namedBindings?.kind === JS.Kind.NamedImports) { + const namedImports = importClause.namedBindings as Draft; + for (const elem of namedImports.elements.elements) { + const specifier = elem.element; + if (specifier.specifier.kind === J.Kind.Identifier && specifier.specifier.simpleName === key) { + specifier.specifier = aliasing(specifier.specifier as Draft, toKey) as Draft; + } else if (specifier.specifier.kind === JS.Kind.Alias) { + const aliasNode = specifier.specifier as Draft; + const propertyName = aliasNode.propertyName.element; + if (propertyName.kind === J.Kind.Identifier && propertyName.simpleName === key) { + propertyName.simpleName = toKey; + } + } + } + } + } + }); + } +} diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts new file mode 100644 index 0000000000..da7a4ee105 --- /dev/null +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -0,0 +1,915 @@ +/* + * 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 {emptyMarkers, findMarker} from "../markers"; +import {randomId, UUID} from "../uuid"; +import { + emptyContainer, + emptySpace, + Expression, + isIdentifier, + isLiteral, + J, + rightPadded, + space, + spaceContainsNewline, + Statement, + TrailingComma +} from "../java"; +import {JS} from "./tree"; +import {cursorOf, deconflict, namesDeclaredWithin} from "./scope"; +import {JavaScriptVisitor} from "./visitor"; +import {ExecutionContext} from "../execution"; + +/** RequireJS and Dojo write `define`; UI5 namespaces it as `sap.ui.define`. */ +export const DEFAULT_AMD_CALLEES: readonly string[] = ["define", "require"]; + +export interface AmdBlock { + /** -1 where the block is written without one, as `define(factory)`. */ + readonly dependenciesIndex: number; + readonly dependencies: J.NewArray; + readonly factoryIndex: number; + readonly factory: J.MethodDeclaration | J.Lambda; +} + +export function amdBlockOf( + call: J.MethodInvocation, + callees: readonly string[] = DEFAULT_AMD_CALLEES +): AmdBlock | undefined { + if (!isAmdCallee(call, callees)) { + return undefined; + } + const args = call.arguments.elements; + const dependenciesIndex = args.findIndex(arg => arg.element.kind === J.Kind.NewArray); + const factoryIndex = dependenciesIndex >= 0 ? + dependenciesIndex + 1 : + args.findIndex(arg => asFactory(arg.element) !== undefined); + const factory = factoryIndex >= 0 && factoryIndex < args.length ? + asFactory(args[factoryIndex].element) : + undefined; + if (factory === undefined) { + return undefined; + } + const dependencies = dependenciesIndex >= 0 ? + args[dependenciesIndex].element as J.NewArray : + noDependencies(); + return {dependenciesIndex, dependencies, factoryIndex, factory}; +} + +/** Stands in for the array a block written without one would have had. */ +function noDependencies(): J.NewArray { + return { + kind: J.Kind.NewArray, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + dimensions: [], + initializer: emptyContainer() + }; +} + +/** + * A callee written without a dot (`"define"`) matches by simple name on any receiver, so it + * also matches `foo.define(...)`; a dotted callee (`"sap.ui.define"`) requires the whole path. + */ +function isAmdCallee(call: J.MethodInvocation, callees: readonly string[]): boolean { + // The name check first: this runs for every call in a file, and almost none are these. + const simpleName = call.name.simpleName; + return callees.some(callee => { + const last = callee.substring(callee.lastIndexOf(".") + 1); + return simpleName === last && (callee === last || namespaceOf(call) === callee); + }); +} + +/** The dotted path a call is written under, `sap.ui.define` for `sap.ui.define(…)`. */ +function namespaceOf(call: J.MethodInvocation): string | undefined { + const segments: string[] = [call.name.simpleName]; + let node: J | undefined = call.select?.element; + while (node !== undefined) { + if (node.kind === J.Kind.FieldAccess) { + const access = node as J.FieldAccess; + segments.unshift(access.name.element.simpleName); + node = access.target; + } else if (isIdentifier(node)) { + segments.unshift(node.simpleName); + return segments.join("."); + } else { + return undefined; + } + } + return segments.join("."); +} + +/** A factory is written either as a function expression or as an arrow function. */ +function asFactory(argument: J): J.MethodDeclaration | J.Lambda | undefined { + if (argument.kind === JS.Kind.StatementExpression) { + const statement = (argument as JS.StatementExpression).statement; + return statement.kind === J.Kind.MethodDeclaration ? statement as J.MethodDeclaration : undefined; + } + return argument.kind === JS.Kind.ArrowFunction ? (argument as JS.ArrowFunction).lambda : undefined; +} + +/** + * The elements of a `[]` or `()`, which the parser fills with a single `J.Empty` when + * there are none. + */ +export function present(elements: readonly J.RightPadded[]): J.RightPadded[] { + return elements.length === 1 && elements[0].element.kind === J.Kind.Empty ? [] : [...elements]; +} + +export function elementsOf(block: AmdBlock): J.RightPadded[] { + return present(block.dependencies.initializer?.elements ?? []); +} + +export function parametersOf(block: AmdBlock): J.RightPadded[] { + return present(block.factory.kind === J.Kind.MethodDeclaration ? + (block.factory as J.MethodDeclaration).parameters.elements : + normalizeArrowParameters((block.factory as J.Lambda).parameters.parameters)); +} + +/** + * An arrow function's parameter list isn't built the way every other comma-separated list in + * the tree is: a trailing comma is an extra `J.Empty` entry rather than a `TrailingComma` + * marker, and a parameter's own trailing whitespace sits on the identifier's declaration + * rather than on the list entry that holds it. Folding both into the shape the rest of this + * module already handles lets append/remove treat every kind of factory alike. + */ +function normalizeArrowParameters(raw: readonly J.RightPadded[]): J.RightPadded[] { + if (raw.length === 1 && raw[0].element.kind === J.Kind.Empty) { + return [...raw]; + } + const trailingEmpty = raw.length > 1 && raw[raw.length - 1].element.kind === J.Kind.Empty ? + raw[raw.length - 1] : undefined; + const real = (trailingEmpty === undefined ? raw : raw.slice(0, -1)).map(foldDeclarationTrailingSpace); + if (trailingEmpty === undefined) { + return real; + } + const last = real[real.length - 1]; + const marker: TrailingComma = {kind: J.Markers.TrailingComma, id: randomId(), suffix: trailingEmpty.after}; + return [...real.slice(0, -1), {...last, markers: {...last.markers, markers: [...last.markers.markers, marker]}}]; +} + +/** Moves a VariableDeclarations parameter's trailing whitespace from the identifier's own declaration up to the list entry, when the entry doesn't already carry any. */ +function foldDeclarationTrailingSpace(entry: J.RightPadded): J.RightPadded { + if (entry.element.kind !== J.Kind.VariableDeclarations || + entry.after.whitespace !== "" || entry.after.comments.length > 0) { + return entry; + } + const declaration = entry.element as J.VariableDeclarations; + const variable = declaration.variables[0]; + if (variable === undefined) { + return entry; + } + const folded: J.VariableDeclarations = { + ...declaration, + variables: [{...variable, after: emptySpace}, ...declaration.variables.slice(1)] + }; + return {...entry, element: folded, after: variable.after}; +} + +export function dependencyNames(block: AmdBlock): string[] { + return elementsOf(block).map(padded => { + const element = padded.element; + return isLiteral(element) && typeof element.value === "string" ? element.value : ""; + }); +} + +export function parameterNames(block: AmdBlock): (string | undefined)[] { + return parametersOf(block).map(padded => identifierOf(padded.element)?.simpleName); +} + +export function identifierOf(parameter: J): J.Identifier | undefined { + if (parameter.kind === J.Kind.VariableDeclarations) { + const name = (parameter as J.VariableDeclarations).variables[0]?.element.name; + return name !== undefined && isIdentifier(name) ? name : undefined; + } + return isIdentifier(parameter) ? parameter : undefined; +} + +/** + * Where an entry's leading whitespace sits. A dependency string carries it directly, while + * a factory parameter carries it on the identifier inside the declaration that wraps it. + */ +interface Slot { + prefixOf(element: T): J.Space; + + withPrefix(element: T, prefix: J.Space): T; +} + +const dependencySlot: Slot = { + prefixOf: element => element.prefix, + withPrefix: (element, prefix) => ({...element, prefix}) +}; + +const parameterSlot: Slot = { + prefixOf: element => identifierOf(element)?.prefix ?? element.prefix, + withPrefix: (element, prefix) => { + if (element.kind !== J.Kind.VariableDeclarations) { + return {...element, prefix}; + } + const declaration = element as J.VariableDeclarations; + const variable = declaration.variables[0]; + if (variable === undefined) { + return element; + } + return { + ...declaration, + variables: [ + {...variable, element: {...variable.element, name: {...variable.element.name, prefix}}}, + ...declaration.variables.slice(1) + ] + }; + } +}; + +/** + * A trailing comma is a marker on the entry it follows, so it has to travel to whichever + * entry ends up last. Left where it was, it prints as a second comma and the array gains a + * hole, which shifts every dependency after it out of step with its parameter. + */ +function moveTrailingComma( + from: J.RightPadded, + to: J.RightPadded +): {from: J.RightPadded, to: J.RightPadded} { + const trailing = findMarker(from, J.Markers.TrailingComma); + if (trailing === undefined) { + return {from, to}; + } + return { + from: {...from, markers: {...from.markers, markers: from.markers.markers.filter(m => m !== trailing)}}, + to: {...to, markers: {...to.markers, markers: [...to.markers.markers, trailing]}} + }; +} + +/** + * The whitespace that separates one entry from the next, taken from the entries already + * there so that a block listing its dependencies one per line keeps doing so. + */ +function separator(entries: readonly J.RightPadded[], slot: Slot): J.Space { + if (entries.length >= 2) { + return slot.prefixOf(entries[1].element); + } + const first = slot.prefixOf(entries[0].element); + return spaceContainsNewline(first) ? first : space(" "); +} + +/** + * Appends `element`. The trailing entry's padding holds the whitespace before the closing + * bracket, so it has to move along with the position. + */ +function appendEntry( + entries: readonly J.RightPadded[], + element: T, + slot: Slot +): J.RightPadded[] { + const last = entries[entries.length - 1]; + if (last === undefined) { + return [rightPadded(element, emptySpace)]; + } + const positioned = slot.withPrefix(element, separator(entries, slot)); + const trailing = splitTrailingComments(last.after); + const moved = moveTrailingComma({...last, after: trailing.comments}, rightPadded(positioned, trailing.whitespace)); + return [...entries.slice(0, -1), moved.from, moved.to]; +} + +/** + * Splits an entry's trailing padding when it stops being last. Plain whitespace only ever + * positioned the closing delimiter, so it travels to whichever entry becomes the new last. A + * comment documents the entry it follows and keeps its own trailing newline, so it stays put + * whole — a line comment with nothing left after it would otherwise swallow the comma the + * printer inserts right after this padding. + */ +function splitTrailingComments(after: J.Space): {comments: J.Space, whitespace: J.Space} { + return after.comments.length === 0 ? + {comments: {...after, whitespace: ""}, whitespace: space(after.whitespace)} : + {comments: after, whitespace: emptySpace}; +} + +/** + * The plain-whitespace part of a leading prefix whose entry is about to be removed. A comment in + * that prefix documents the entry it precedes and is dropped along with it; the whitespace only + * ever positioned that entry after the opening bracket or a comma, so it's what travels to + * whichever entry takes its place. + */ +function leadingWhitespaceOf(prefix: J.Space): string { + return prefix.comments.length === 0 ? prefix.whitespace : ""; +} + +/** + * Removes the entry at `index`. The first entry carries no separating whitespace and the + * last carries the whitespace before the closing bracket, so removing either hands its + * padding to whichever entry takes its place. + */ +function removeEntry( + entries: readonly J.RightPadded[], + index: number, + slot: Slot +): J.RightPadded[] { + if (index < 0 || index >= entries.length) { + return [...entries]; + } + const remaining = [...entries]; + const [removed] = remaining.splice(index, 1); + if (remaining.length === 0) { + return remaining; + } + if (index === 0) { + // Spread the survivor's own prefix rather than replacing it outright, so a comment it + // already carries survives; only its plain whitespace is replaced. + const whitespace = leadingWhitespaceOf(slot.prefixOf(removed.element)); + const survivor = remaining[0]; + remaining[0] = { + ...survivor, + element: slot.withPrefix(survivor.element, {...slot.prefixOf(survivor.element), whitespace}) + }; + } else if (index === remaining.length) { + const last = {...remaining[index - 1], after: removed.after}; + remaining[index - 1] = moveTrailingComma(removed, last).to; + } + return remaining; +} + +function emptyExpression(): J.Empty { + return {kind: J.Kind.Empty, id: randomId(), prefix: emptySpace, markers: emptyMarkers}; +} + +function withElements(dependencies: J.NewArray, elements: J.RightPadded[]): J.NewArray { + const initializer = dependencies.initializer!; + return { + ...dependencies, + initializer: { + ...initializer, + elements: elements.length === 0 ? [rightPadded(emptyExpression(), emptySpace)] : elements + } + }; +} + +function withParameters( + factory: J.MethodDeclaration | J.Lambda, + parameters: J.RightPadded[] +): J.MethodDeclaration | J.Lambda { + const elements = parameters.length === 0 ? [rightPadded(emptyExpression(), emptySpace)] : parameters; + if (factory.kind === J.Kind.MethodDeclaration) { + const method = factory as J.MethodDeclaration; + return {...method, parameters: {...method.parameters, elements: elements as J.RightPadded[]}}; + } + const lambda = factory as J.Lambda; + // Unparenthesized arrow parameters are only valid JavaScript for exactly one parameter. + if (lambda.parameters.parenthesized || parameters.length === 1) { + return {...lambda, parameters: {...lambda.parameters, parameters: elements}}; + } + return parenthesize(lambda, elements); +} + +/** + * A bare arrow parameter has no closing delimiter of its own — the space before `=>` sits on + * the parameter itself. Wrapping it in parens turns that into the space before the new `)`, + * so it has to move to `arrow` instead, where it will actually print before `=>` again. + */ +function parenthesize(lambda: J.Lambda, elements: J.RightPadded[]): J.Lambda { + const last = elements[elements.length - 1]; + const arrowSpace = last.after.whitespace === "" ? lambda.arrow : last.after; + return { + ...lambda, + parameters: {...lambda.parameters, parenthesized: true, parameters: [...elements.slice(0, -1), {...last, after: emptySpace}]}, + arrow: arrowSpace + }; +} + +function identifier(name: string): J.Identifier { + return { + kind: J.Kind.Identifier, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + annotations: [], + simpleName: name, + type: undefined, + fieldType: undefined + }; +} + +function dependencyLiteral(module: string, quote: string): J.Literal { + return { + kind: J.Kind.Literal, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + value: module, + valueSource: `${quote}${module}${quote}` + }; +} + +/** The quote the block's own dependencies use, so a new one matches them. */ +function quoteOf(block: AmdBlock): string { + for (const padded of elementsOf(block)) { + const source = isLiteral(padded.element) ? (padded.element as J.Literal).valueSource : undefined; + const quote = source?.charAt(0); + if (quote === '"' || quote === "'") { + return quote; + } + } + return '"'; +} + +function parameterDeclaration(name: string): J.VariableDeclarations { + return { + kind: J.Kind.VariableDeclarations, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + leadingAnnotations: [], + modifiers: [], + variables: [rightPadded({ + kind: J.Kind.NamedVariable, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + name: identifier(name), + dimensionsAfterName: [] + } as J.VariableDeclarations.NamedVariable, emptySpace)] + }; +} + +/** + * Adds a dependency and its binding, keeping the array and the parameter list index-aligned. + * Refuses (returns `undefined`) unless the two are already the same length: fewer parameters + * than dependencies would invent a binding for one an author left unbound on purpose, and more + * would pair the new dependency with an existing surplus parameter instead of a fresh one — + * either way corrupting the pairing this module exists to protect. + */ +export function withDependency( + call: J.MethodInvocation, + block: AmdBlock, + module: string, + binding: string +): J.MethodInvocation | undefined { + if (parametersOf(block).length !== elementsOf(block).length) { + return undefined; + } + const dependencies = withElements( + block.dependencies, + appendEntry(elementsOf(block), dependencyLiteral(module, quoteOf(block)), dependencySlot)); + const factory = withParameters( + block.factory, + appendEntry(parametersOf(block), parameterDeclaration(binding), parameterSlot)); + return withParts(call, block, dependencies, factory); +} + +export function withoutDependencyAt( + call: J.MethodInvocation, + block: AmdBlock, + index: number +): J.MethodInvocation { + const dependencies = withElements(block.dependencies, removeEntry(elementsOf(block), index, dependencySlot)); + const parameters = parametersOf(block); + const factory = index >= parameters.length ? + block.factory : + withParameters(block.factory, removeEntry(parameters, index, parameterSlot)); + return withParts(call, block, dependencies, factory); +} + +/** + * Swaps the module the dependency at `index` names, leaving its parameter and every other + * entry's position untouched — simpler than a removal and a fresh append, which would shift + * every dependency after it and re-litigate the parameter pairing this module exists to protect. + */ +export function withDependencyModuleAt( + call: J.MethodInvocation, + block: AmdBlock, + index: number, + module: string +): J.MethodInvocation { + const quote = quoteOf(block); + const elements = [...elementsOf(block)]; + const entry = elements[index]; + const literal = entry.element as J.Literal; + const updated: J.Literal = {...literal, value: module, valueSource: `${quote}${module}${quote}`}; + elements[index] = {...entry, element: updated}; + const dependencies = withElements(block.dependencies, elements); + return withParts(call, block, dependencies, block.factory); +} + +function withParts( + call: J.MethodInvocation, + block: AmdBlock, + dependencies: J.NewArray, + factory: J.MethodDeclaration | J.Lambda +): J.MethodInvocation { + const args = [...call.arguments.elements]; + let factoryIndex = block.factoryIndex; + if (block.dependenciesIndex >= 0) { + args[block.dependenciesIndex] = {...args[block.dependenciesIndex], element: dependencies}; + } else if (present(dependencies.initializer?.elements ?? []).length > 0) { + // The array takes the position the factory held, so it inherits what led up to it. + const leading = args[factoryIndex].element; + args[factoryIndex] = {...args[factoryIndex], element: {...leading, prefix: space(" ")}}; + args.splice(factoryIndex, 0, rightPadded({...dependencies, prefix: leading.prefix}, emptySpace)); + factoryIndex += 1; + } + args[factoryIndex] = {...args[factoryIndex], element: restoreFactory(args[factoryIndex].element, factory)}; + return {...call, arguments: {...call.arguments, elements: args}}; +} + +/** Puts the rewritten factory back into whichever wrapper the original was written as. */ +function restoreFactory(original: Expression, factory: J.MethodDeclaration | J.Lambda): Expression { + if (original.kind === JS.Kind.StatementExpression) { + const statementExpression: JS.StatementExpression = { + ...(original as JS.StatementExpression), + statement: factory as J.MethodDeclaration + }; + return statementExpression; + } + const arrowFunction: JS.ArrowFunction = {...(original as JS.ArrowFunction), lambda: factory as J.Lambda}; + return arrowFunction; +} + +/** The subset of `MaybeBindOptions` that selects which callees introduce an AMD block. */ +export interface AmdCalleeOptions { + /** Callees that introduce an AMD block. */ + amdCallee?: string | readonly string[]; +} + +export function calleesOf(options?: AmdCalleeOptions): readonly string[] { + const callee = options?.amdCallee; + return callee === undefined ? DEFAULT_AMD_CALLEES : typeof callee === "string" ? [callee] : callee; +} + +/** The nearest AMD block the cursor sits inside, which is the one a binding belongs to. */ +export function enclosingAmdBlock( + visitor: JavaScriptVisitor, + options?: AmdCalleeOptions +): {call: J.MethodInvocation, block: AmdBlock} | undefined { + const callees = calleesOf(options); + let cursor = cursorOf(visitor); + while (cursor !== undefined) { + const value = cursor.value as J | undefined; + if (value?.kind === J.Kind.MethodInvocation) { + const block = amdBlockOf(value as J.MethodInvocation, callees); + if (block !== undefined) { + return {call: value as J.MethodInvocation, block}; + } + } + cursor = cursor.parent; + } + return undefined; +} + +/** The conventional local name for a module: its last path segment. */ +export function lastSegment(module: string): string { + return module.substring(module.lastIndexOf("/") + 1); +} + +// Always-reserved words, plus the strict-mode-only ones (module code is always strict) and +// `await`/`yield`, contextual elsewhere but a trap to bind regardless of where they're legal. +const RESERVED_WORDS = new Set([ + "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", + "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "import", + "in", "instanceof", "new", "null", "return", "super", "switch", "this", "throw", "true", "try", + "typeof", "var", "void", "while", "with", + "await", "implements", "interface", "let", "package", "private", "protected", "public", + "static", "yield" +]); + +/** + * `lastSegment(module)`, or `undefined` where that string cannot bind a name — a scoped + * package's `@scope/my-lib`, a subpath's `lodash.merge`, a `-`/`.`-bearing name, or a reserved + * word are all real module strings that are not legal identifiers. ASCII identifiers only — + * stricter than JavaScript itself, which allows Unicode, but refusing there is safe. + */ +export function derivedBindingName(module: string): string | undefined { + const segment = lastSegment(module); + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment) && !RESERVED_WORDS.has(segment) ? segment : undefined; +} + +/** Queued reservations already targeting this block: the shared source for both dedup and deconfliction. */ +function queuedFor(visitor: JavaScriptVisitor, blockId: UUID): AddAmdDependency[] { + return visitor.afterVisit.filter((v): v is AddAmdDependency => v instanceof AddAmdDependency && v.blockId === blockId); +} + +/** + * A local binding for `module` on the AMD lane, creating one where the block does not already + * have it, or `undefined` where no safe binding exists. + * + * The name is decided from the cursor and returned at once; the edit that creates the binding + * is deferred onto `visitor.afterVisit`, as `bindImport`'s is. + */ +export function bindAmd( + visitor: JavaScriptVisitor, + amd: {call: J.MethodInvocation, block: AmdBlock}, + module: string, + preferredName: string | undefined, + callees: readonly string[], + pinned: boolean = false +): string | undefined { + const modules = dependencyNames(amd.block); + const bindings = parameterNames(amd.block); + + const declared = module === "" ? -1 : modules.indexOf(module); + if (declared >= 0) { + return bindings[declared]; + } + + // Two calls naming the same module at the same block are the same request (ADR 0013), + // answered from whichever reservation is already queued rather than doubling the dependency. + const queued = queuedFor(visitor, amd.call.id); + const reserved = queued.find(v => v.module === module)?.binding; + if (reserved !== undefined) { + return reserved; + } + + // A parameter can only be appended at the end, so a block whose dependency and parameter + // counts already disagree would bind the new module against the wrong parameter either way. + if (bindings.length !== elementsOf(amd.block).length) { + return undefined; + } + + if (preferredName === undefined && derivedBindingName(module) === undefined) { + return undefined; + } + // A parameter is in scope across the whole factory, and the queue hands this name to later + // requests at cursors inside it, so every name the factory declares is one it has to clear. + const taken = [...bindings, ...namesDeclaredWithin(amd.block.factory), ...queued.map(v => v.binding)]; + const requested = preferredName ?? derivedBindingName(module)!; + const binding = deconflict(requested, candidate => taken.includes(candidate)); + if (pinned && binding !== requested) { + // An alias is bound verbatim or not at all, since the caller may already have emitted + // code naming it, and a deconflicted spelling would leave that unbound. + return undefined; + } + visitor.afterVisit.push(new AddAmdDependency(amd.call.id, module, binding, callees)); + return binding; +} + +/** The factory's body: a `J.Block` for `function(){}` and most arrows, or the bare expression for `() => expr`. */ +export function bodyOf(block: AmdBlock): J | undefined { + return block.factory.kind === J.Kind.MethodDeclaration ? + (block.factory as J.MethodDeclaration).body : + (block.factory as J.Lambda).body; +} + +/** + * Whether the factory body references `name`. This counts any identifier of that name, + * including a member in a field access, since a stray dependency is fine where a shadowed one + * is not. `missingBodyAnswer` covers a body-less factory: `false` for an addition (nothing to + * conflict with), `true` for a removal (nothing to prove the binding unused). + */ +export async function references(block: AmdBlock, name: string, missingBodyAnswer: boolean): Promise { + const body = bodyOf(block); + if (body === undefined) { + return missingBodyAnswer; + } + let found = false; + const finder = new class extends JavaScriptVisitor { + override async visitIdentifier(id: J.Identifier, c: ExecutionContext) { + if (id.simpleName === name) { + found = true; + } + return super.visitIdentifier(id, c); + } + }; + await finder.visit(body, new ExecutionContext()); + return found; +} + +/** + * Every name the factory body references, one walk for the whole block rather than one per + * name. `missingBodyAnswer` means what it means on `references`: the answer `.has` gives, for + * every name, when the factory has no body to walk. + */ +export async function namesUsed(block: AmdBlock, missingBodyAnswer: boolean): Promise<{has(name: string): boolean}> { + const body = bodyOf(block); + if (body === undefined) { + return {has: () => missingBodyAnswer}; + } + const names = new Set(); + const collector = new class extends JavaScriptVisitor { + override async visitIdentifier(id: J.Identifier, c: ExecutionContext) { + names.add(id.simpleName); + return super.visitIdentifier(id, c); + } + }; + await collector.visit(body, new ExecutionContext()); + return names; +} + +/** + * Applies the dependency a `maybeBind` call settled on. The block is found by id: its caller + * has already emitted a reference to the binding, so a block that cannot be found is an error + * rather than a skipped edit. + */ +export class AddAmdDependency

extends JavaScriptVisitor

{ + constructor( + readonly blockId: UUID, + readonly module: string, + readonly binding: string, + readonly callees: readonly string[] + ) { + super(); + } + + private applied = false; + + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: P): Promise { + const visited = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit; + if (!this.applied) { + throw new Error( + `No AMD block ${this.blockId} to declare '${this.module}' on, but '${this.binding}' was reported bound`); + } + return visited; + } + + override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + if (visited.id !== this.blockId) { + return visited; + } + const block = amdBlockOf(visited, this.callees); + if (block === undefined) { + return visited; + } + this.applied = true; + if (!(await references(block, this.binding, false))) { + // Nothing referenced the binding, so the caller asked and then did not use the answer. + return visited; + } + // Another edit in the same visit can drop or add a factory parameter, reopening a count + // mismatch bindAmd's own check already cleared — an error here for the same reason a missing + // block is one. + const dependency = withDependency(visited, block, this.module, this.binding); + if (dependency === undefined) { + throw new Error( + `AMD block ${this.blockId} no longer has matching dependency and parameter counts, ` + + `so '${this.module}' could not be declared for '${this.binding}', which was reported bound`); + } + return dependency; + } +} + +/** + * Applies a `maybeRebind` call's decision on the AMD lane: swaps the dependency's module in + * place, found by the block's tree id and the module it named when the caller asked. + */ +export class RebindAmdDependency

extends JavaScriptVisitor

{ + constructor( + readonly blockId: UUID, + readonly fromModule: string, + readonly toModule: string, + readonly callees: readonly string[] + ) { + super(); + } + + private applied = false; + + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: P): Promise { + const visited = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit; + if (!this.applied) { + throw new Error(`No AMD block ${this.blockId} to rebind '${this.fromModule}' to '${this.toModule}' on`); + } + return visited; + } + + override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + if (visited.id !== this.blockId) { + return visited; + } + const block = amdBlockOf(visited, this.callees); + if (block === undefined) { + return visited; + } + this.applied = true; + const index = this.fromModule === "" ? -1 : dependencyNames(block).indexOf(this.fromModule); + if (index < 0) { + throw new Error( + `AMD block ${this.blockId} no longer has dependency '${this.fromModule}' to rebind to '${this.toModule}'`); + } + return withDependencyModuleAt(visited, block, index, this.toModule); + } +} + +/** + * The AMD counterpart to `RemoveImport`: drops a dependency the block's body no longer names, + * along with the parameter bound to it. A dependency the factory takes no parameter for is + * loaded for its side effects and stays, matching `bindAmd`'s own read of such a block. A + * `member`-scoped request is a no-op here — see `maybeUnbind`. + */ +export class RemoveAmdDependency

extends JavaScriptVisitor

{ + constructor( + readonly module: string, + readonly member?: string, + readonly callees: readonly string[] = DEFAULT_AMD_CALLEES + ) { + super(); + } + + override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + if (this.member !== undefined) { + return visited; + } + const block = amdBlockOf(visited, this.callees); + if (block === undefined) { + return visited; + } + const index = dependencyNames(block).indexOf(this.module); + if (index < 0) { + return visited; + } + const binding = parameterNames(block)[index]; + if (binding === undefined || await references(block, binding, true)) { + return visited; + } + return withoutDependencyAt(visited, block, index); + } +} + +/** + * Drops AMD bindings a rewrite left unreferenced. One already unused beforehand stays: it is + * loaded for its side effects, so dropping it changes what the module loads. Needing the tree as + * it stood before the rewrite is why this takes both and does not defer. Blocks are matched by + * the call's id, so one a rewrite rebuilds rather than edits is left alone. + */ +export async function removeNewlyUnusedAmdBindings( + before: JS.CompilationUnit, + after: JS.CompilationUnit, + ctx: ExecutionContext, + options?: AmdCalleeOptions +): Promise { + const callees = calleesOf(options); + const usedBeforeByBlock = new Map>(); + const collector = new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, c: ExecutionContext) { + const block = amdBlockOf(m, callees); + if (block !== undefined) { + usedBeforeByBlock.set(m.id, await usedBindings(block)); + } + return super.visitMethodInvocation(m, c); + } + }; + await collector.visit(before, ctx); + if (usedBeforeByBlock.size === 0) { + return after; + } + + const sweeper = new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, c: ExecutionContext): Promise { + let call = await super.visitMethodInvocation(m, c) as J.MethodInvocation; + const usedBefore = usedBeforeByBlock.get(call.id); + let block = usedBefore === undefined ? undefined : amdBlockOf(call, callees); + if (usedBefore === undefined || block === undefined) { + return call; + } + // A block written `define(factory)` takes `require`, `exports` and `module` from the + // loader, so a parameter there is positional against arguments no dependency list names. + if (block.dependenciesIndex < 0 || parameterNames(block).length !== elementsOf(block).length) { + return call; + } + // withoutDependencyAt only edits the dependency array and parameter list, never the + // body, so which names it references can't change between removals. + const usedNow = await namesUsed(block, true); + for (; ;) { + const bindings = parameterNames(block); + const goneIndex = bindings.findIndex(binding => + binding !== undefined && usedBefore.has(binding) && !usedNow.has(binding)); + if (goneIndex < 0) { + return call; + } + call = withoutDependencyAt(call, block, goneIndex); + // A removal shifts the indices of the dependencies after it, so the block is + // re-derived from `call` rather than reused with stale offsets. + const next = amdBlockOf(call, callees); + if (next === undefined) { + return call; + } + block = next; + } + } + }; + return await sweeper.visit(after, ctx) as JS.CompilationUnit; +} + +/** The block's parameter names that its body actually references, from a single walk of it. */ +async function usedBindings(block: AmdBlock): Promise> { + const usedNames = await namesUsed(block, false); + const used = new Set(); + for (const binding of parameterNames(block)) { + if (binding !== undefined && usedNames.has(binding)) { + used.add(binding); + } + } + return used; +} diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts new file mode 100644 index 0000000000..7caa9311d9 --- /dev/null +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -0,0 +1,449 @@ +/* + * 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 {J} from "../java"; +import {JS} from "./tree"; +import {JavaScriptVisitor} from "./visitor"; +import {compilationUnitOf, cursorOf, declarationsOf, walk} from "./scope"; +import {AddImportOptions, bindImport, existingImportBinding, memberName, moduleNameOf, RebindImport, requiredModuleOf} from "./add-import"; +import {RemoveImport} from "./remove-import"; +import { + AmdCalleeOptions, amdBlockOf, bindAmd, calleesOf, dependencyNames, derivedBindingName, enclosingAmdBlock, + parameterNames, RebindAmdDependency, RemoveAmdDependency +} from "./amd"; + +/** + * A bare string is shorthand for `{module}`, the overwhelmingly common call with nothing else to + * configure. A factory parameter binds a whole module under a name and nothing else, so on the AMD + * lane `member`, `typeOnly`, `sideEffectOnly`, `onlyIfReferenced`, `quoteStyle` and `style` do not + * apply: the first three refuse, and the rest have nothing to shape. + */ +export interface MaybeBindOptions extends AddImportOptions { + /** Callees that introduce an AMD block. UI5 writes `sap.ui.define`, RequireJS and Dojo `define`. */ + amdCallee?: string | readonly string[]; +} + +/** A bare string is shorthand for `{module}`, the overwhelmingly common call with nothing else to configure. */ +export interface MaybeUnbindOptions { + module: string; + + /** The member to remove; unset removes every unused binding of the module. */ + member?: string; + + /** Callees that introduce an AMD block. UI5 writes `sap.ui.define`, RequireJS and Dojo `define`. */ + amdCallee?: string | readonly string[]; +} + +export interface MaybeRebindOptions { + from: {module: string; member?: string}; + to: {module: string; member?: string}; + + /** Callees that introduce an AMD block. UI5 writes `sap.ui.define`, RequireJS and Dojo `define`. */ + amdCallee?: string | readonly string[]; +} + +export interface ModuleBindings { + /** + * The module `localName` refers to, or undefined when it is not a module binding — of any + * shape, so a namespace import's name answers here even though `maybeBind` will not reuse it + * for a plain `{module}` request. See `bindingOf`. + */ + moduleOf(localName: string): string | undefined; + + /** + * The local name bound to `module`, or undefined when nothing binds it — of any shape: a + * namespace import counts, even though `maybeBind` will not treat it as answering a plain + * `{module}` request. What a name can stand in for is `maybeBind`'s question, not this one's. + */ + bindingOf(module: string): string | undefined; + + /** + * The lane these bindings come from, and the one `maybeBind` would use. `"none"` is a + * plain script — no import, export, `require` binding, or enclosing AMD block — which + * `maybeBind` still turns into a module on request; a caller that must not do that checks + * for `"none"` itself. + */ + readonly moduleSystem: "esm" | "amd" | "commonjs" | "none"; +} + +export function moduleBindings( + visitor: JavaScriptVisitor, + options?: AmdCalleeOptions +): ModuleBindings { + const amd = enclosingAmdBlock(visitor, options); + if (amd !== undefined) { + const modules = dependencyNames(amd.block); + const bindings = parameterNames(amd.block); + return { + moduleSystem: "amd", + moduleOf: localName => { + const index = bindings.indexOf(localName); + // `dependencyNames` pads a non-literal element with "" to hold its position; that + // filler names no module, so it answers neither lookup. + return index < 0 || modules[index] === "" ? undefined : modules[index]; + }, + bindingOf: module => { + const index = module === "" ? -1 : modules.indexOf(module); + return index < 0 ? undefined : bindings[index]; + } + }; + } + + const cu = compilationUnitOf(visitor); + const bound = cu === undefined ? [] : moduleObjectBindings(cu); + return { + moduleSystem: cu === undefined ? "none" : + isCommonJs(cu) ? "commonjs" : + hasEsmSyntax(cu) ? "esm" : "none", + moduleOf: localName => bound.find(b => b.name === localName)?.module, + bindingOf: module => bound.find(b => b.module === module)?.name + }; +} + +export function isAmdBlock(node: J, options?: AmdCalleeOptions): boolean { + return node.kind === J.Kind.MethodInvocation && + amdBlockOf(node as J.MethodInvocation, calleesOf(options)) !== undefined; +} + +/** Whether a top-level statement already marks the file as a module: an import or any export form. */ +function hasEsmSyntax(cu: JS.CompilationUnit): boolean { + return cu.statements.some(stmt => { + const element = stmt.element; + // `export {a, b}`, `export * from` and `export default` are their own statement kinds; + // `export class`/`function`/`const` instead carry `export` as a modifier on the + // declaration itself, the same way TypeScript's own AST models it. + const modifiers = (element as {modifiers?: J.Modifier[]} | undefined)?.modifiers; + return element?.kind === JS.Kind.Import || + element?.kind === JS.Kind.ExportDeclaration || + element?.kind === JS.Kind.ExportAssignment || + (modifiers?.some(m => m.keyword === "export") ?? false); + }) || hasTopLevelAwait(cu); +} + +/** + * Whether a statement holds an `await` outside any function of its own — legal only at module + * top level, unlike an `await` inside an `async function`, which says nothing about the file. + */ +function hasTopLevelAwait(cu: JS.CompilationUnit): boolean { + let found = false; + walk(cu.statements, node => { + if (found) { + return false; + } + if (node.kind === JS.Kind.Await) { + found = true; + return false; + } + return node.kind !== J.Kind.MethodDeclaration && node.kind !== J.Kind.Lambda; + }); + return found; +} + +interface ModuleObjectBinding { + name: string; + module: string; + + /** + * `"default"` and `"namespace"` bind different values — a namespace object's default sits at + * `.default` — so only one answers a whole-module request of the matching form. CommonJS has + * no such split: `"require"` answers either. A caller wanting the namespace asks for + * `member: "*"`, which is what a module exporting no default is bound by. + */ + shape: "default" | "namespace" | "require"; + + /** A type-only import erases, so the name it binds stands for no value at runtime. */ + typeOnly: boolean; +} + +/** Whether the file binds its modules with `require`, which decides whether a create is possible. */ +function isCommonJs(cu: JS.CompilationUnit): boolean { + if (cu.sourcePath.endsWith(".cjs") || cu.sourcePath.endsWith(".cts")) { + return true; + } + // Node treats these as ES modules regardless of what they contain, the same way + // `AddImport`'s own `determineImportStyle` reads them as ES6-preferring; `.js`/`.ts`/`.tsx` + // stay ambiguous and fall through to the statements below. + if (cu.sourcePath.endsWith(".mjs") || cu.sourcePath.endsWith(".mts")) { + return false; + } + if (hasEsmSyntax(cu)) { + return false; + } + return cu.statements.some(stmt => + declarationsOf(stmt.element).some(d => requiredModule(d) !== undefined)); +} + +/** The module a `const X = require("m")` declaration names, for the one variable it declares. */ +function requiredModule(declaration: J.VariableDeclarations): string | undefined { + const variables = declaration.variables; + const initializer = variables.length === 1 ? variables[0].element?.initializer?.element : undefined; + return initializer?.kind === J.Kind.MethodInvocation + ? requiredModuleOf(initializer as J.MethodInvocation) + : undefined; +} + +/** + * The module a `const X = await import("m")` declaration names, for the one variable it declares. + * A dynamic import resolves to the module namespace object, the same value `import * as X` + * binds, so it shares that shape rather than getting one of its own. + */ +function dynamicallyImportedModule(declaration: J.VariableDeclarations): string | undefined { + const variables = declaration.variables; + const initializer = variables.length === 1 ? variables[0].element?.initializer?.element : undefined; + const awaited = initializer?.kind === JS.Kind.Await ? (initializer as JS.Await).expression : undefined; + // `import(...)` is a keyword, not an identifier, so the parser maps it to `JS.FunctionCall` + // rather than the `J.MethodInvocation` an ordinary call gets. + if (awaited?.kind !== JS.Kind.FunctionCall) { + return undefined; + } + const call = awaited as JS.FunctionCall; + const callee = call.function?.element; + if (callee?.kind !== J.Kind.Identifier || (callee as J.Identifier).simpleName !== "import") { + return undefined; + } + const argument = call.arguments.elements[0]?.element; + return argument?.kind === J.Kind.Literal && typeof (argument as J.Literal).value === "string" + ? (argument as J.Literal).value as string + : undefined; +} + +/** Bindings from top-level `const X = ` declarations, all of one shape. */ +function wholeModuleBindingsVia( + cu: JS.CompilationUnit, + moduleOf: (declaration: J.VariableDeclarations) => string | undefined, + shape: ModuleObjectBinding["shape"] +): ModuleObjectBinding[] { + const bindings: ModuleObjectBinding[] = []; + for (const stmt of cu.statements) { + for (const declaration of declarationsOf(stmt.element)) { + const module = moduleOf(declaration); + const name = declaration.variables[0]?.element?.name; + if (module !== undefined && name?.kind === J.Kind.Identifier) { + bindings.push({name: (name as J.Identifier).simpleName, module, shape, typeOnly: false}); + } + } + } + return bindings; +} + +/** Only whole-module bindings: a named member does not name the module object. */ +function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { + const bindings: ModuleObjectBinding[] = []; + for (const stmt of cu.statements) { + const statement = stmt.element; + if (statement?.kind !== JS.Kind.Import) { + continue; + } + const jsImport = statement as JS.Import; + const specifier = jsImport.moduleSpecifier?.element; + if (specifier?.kind !== J.Kind.Literal) { + continue; + } + const module = (specifier as J.Literal).value; + if (typeof module !== "string") { + continue; + } + const clause = jsImport.importClause; + const typeOnly = clause?.typeOnly ?? false; + if (clause?.name?.element?.kind === J.Kind.Identifier) { + bindings.push({name: (clause.name.element as J.Identifier).simpleName, module, shape: "default", typeOnly}); + } + // `namedBindings` is a `JS.Alias` only for `import * as X from "m"`; a renamed + // named import (`{a as b}`) nests its alias inside `NamedImports` instead. + const named = clause?.namedBindings; + if (named?.kind === JS.Kind.Alias) { + const alias = (named as JS.Alias).alias; + if (alias?.kind === J.Kind.Identifier) { + bindings.push({name: (alias as J.Identifier).simpleName, module, shape: "namespace", typeOnly}); + } + } + } + bindings.push(...wholeModuleBindingsVia(cu, requiredModule, "require")); + bindings.push(...wholeModuleBindingsVia(cu, dynamicallyImportedModule, "namespace")); + return bindings; +} + +/** + * Whether `binding` answers a whole-module request: the namespace form when `wantsNamespace`, and + * never a type-only import for a value, which erases and would leave the reference unbound. + */ +function answersWholeModuleRequest(binding: ModuleObjectBinding, wantsNamespace: boolean, typeOnly: boolean): boolean { + return binding.typeOnly === typeOnly && + (binding.shape === "require" || binding.shape === (wantsNamespace ? "namespace" : "default")); +} + +/** + * A local binding for `module` or one of its members, creating one where none exists, or + * `undefined` where no safe binding is possible. The lane — AMD or ESM/CommonJS — is decided + * from the cursor; AMD binds only the whole module, so a `member` request there refuses rather + * than guess. `onlyIfReferenced` defaults to true, so the import may never appear. + */ +export function maybeBind( + visitor: JavaScriptVisitor, + options: MaybeBindOptions | string +): string | undefined { + if (typeof options === "string") { + options = {module: options}; + } + const module = moduleNameOf(options.module); + const key = memberName(options.member); + + const amd = enclosingAmdBlock(visitor, options); + if (amd !== undefined) { + if (key !== undefined || options.sideEffectOnly) { + // Nor can a factory parameter load a module without binding it to a name. + return undefined; + } + return bindAmd(visitor, amd, module, options.alias ?? options.preferredName, + calleesOf(options), options.alias !== undefined); + } + + const cu = compilationUnitOf(visitor); + const isWholeModule = !options.sideEffectOnly && (key === undefined || key === "*"); + if (isWholeModule) { + const bound = cu && moduleObjectBindings(cu).find(b => + b.module === module && answersWholeModuleRequest(b, key === "*", options.typeOnly ?? false) && + // A pinned alias asks for a binding of that name, so another name for the same + // module does not answer it; `bindImport`'s own lookup applies the same rule. + (options.alias === undefined || b.name === options.alias)); + if (bound !== undefined) { + return bound.name; + } + } + + if (isWholeModule && options.preferredName === undefined && derivedBindingName(module) === undefined) { + // The module's last path segment is not a legal identifier, and the caller named no + // preference of its own — there is no name left to bind it to. + return undefined; + } + + // `bindImport`'s own lookup finds and reuses a member-specific binding on its own, so + // refusal here only has to gate the point where it would create a new one. + const refuseCreate = cu !== undefined && isCommonJs(cu); + return bindImport(visitor, { + ...options, + preferredName: options.preferredName ?? (isWholeModule ? derivedBindingName(module) : undefined) + }, refuseCreate); +} + +/** + * Removes `module`'s import(s) where unused, or one `member` of it — `'default'` and `'*'` select + * the default and namespace import regardless of local name. A member-scoped request does not + * apply to an AMD dependency, which binds a module rather than one of its members. + */ +export function maybeUnbind(visitor: JavaScriptVisitor, options: MaybeUnbindOptions | string): void { + if (typeof options === "string") { + options = {module: options}; + } + const callees = calleesOf(options); + const queued = visitor.afterVisit || []; + if (!queued.some(v => v instanceof RemoveImport && v.module === options.module && v.member === options.member)) { + visitor.afterVisit.push(new RemoveImport(options.module, options.member)); + } + // Both queue unconditionally so the caller need not know which lane the file uses: each + // visitor removes whatever matching construct it finds, ESM import or AMD dependency, and a + // file with both gets both removed. `amdCallee` says which block a call means, not how + // anything prints, so — unlike `preferredName`/`quoteStyle` on the bind side — it is part of + // the dedup key. + if (!queued.some(v => v instanceof RemoveAmdDependency && v.module === options.module && + v.member === options.member && sameCallees(v.callees, callees))) { + visitor.afterVisit.push(new RemoveAmdDependency(options.module, options.member, callees)); + } +} + +function sameCallees(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((callee, i) => callee === b[i]); +} + +/** Which of an import clause's three slots `member` binds — `import`, `import *`, or `import {}`. */ +function bindingShape(member: string | undefined): "default" | "namespace" | "named" { + const key = memberName(member); + return key === undefined ? "default" : key === "*" ? "namespace" : "named"; +} + +/** + * Moves the binding for `from` to `to`, keeping the local name it already had — the primitive + * behind a member rename or a module move. Returns `undefined`, changing nothing, where the move + * is not safely expressible; the refusals are listed in CLAUDE.md: JavaScript module bindings. + */ +export function maybeRebind(visitor: JavaScriptVisitor, options: MaybeRebindOptions): string | undefined { + const amd = enclosingAmdBlock(visitor, options); + if (amd !== undefined) { + if (memberName(options.from.member) !== undefined || memberName(options.to.member) !== undefined) { + return undefined; + } + const index = dependencyNames(amd.block).indexOf(options.from.module); + const binding = index < 0 ? undefined : parameterNames(amd.block)[index]; + if (binding === undefined) { + return undefined; + } + const callees = calleesOf(options); + const from = options.from.module; + if (!(visitor.afterVisit || []).some(v => v instanceof RebindAmdDependency && + v.blockId === amd.call.id && v.fromModule === from && sameCallees(v.callees, callees))) { + visitor.afterVisit.push(new RebindAmdDependency(amd.call.id, from, options.to.module, callees)); + } + return binding; + } + + const cu = compilationUnitOf(visitor); + if (cu === undefined) { + return undefined; + } + const existing = existingImportBinding(cu, options.from.module, options.from.member); + if (existing === undefined) { + return undefined; + } + if (existing.onlyMemberOfStatement && bindingShape(options.from.member) !== bindingShape(options.to.member)) { + return undefined; + } + if (!existing.onlyMemberOfStatement && isCommonJs(cu)) { + return undefined; + } + visitor.afterVisit.push(new RebindImport(options.from, options.to, existing.localName)); + return existing.localName; +} + +/** + * @deprecated Use {@link maybeUnbind} instead — this is a call-shape change, not a behaviour + * change: `maybeRemoveImport(v, module, member)` is `maybeUnbind(v, {module, member})`. + */ +export function maybeRemoveImport(visitor: JavaScriptVisitor, module: string, member?: string): void { + maybeUnbind(visitor, {module, member}); +} + +/** + * @deprecated Use {@link maybeBind} instead. Beyond binding through an AMD factory parameter, + * `maybeBind` returns `undefined` rather than creating an import where the file binds its modules + * with `require`, or where no legal identifier can be derived from the module and none was named. + */ +export function maybeAddImport( + visitor: JavaScriptVisitor, + options: AddImportOptions & { sideEffectOnly: true } +): undefined; +export function maybeAddImport( + visitor: JavaScriptVisitor, + options: AddImportOptions & { sideEffectOnly?: false } +): string | undefined; +export function maybeAddImport( + visitor: JavaScriptVisitor, + options: AddImportOptions +): string | undefined; +export function maybeAddImport( + visitor: JavaScriptVisitor, + options: AddImportOptions +): string | undefined { + return maybeBind(visitor, options); +} diff --git a/rewrite-javascript/rewrite/src/javascript/index.ts b/rewrite-javascript/rewrite/src/javascript/index.ts index 7e0ddc986f..b0dbd2b824 100644 --- a/rewrite-javascript/rewrite/src/javascript/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/index.ts @@ -31,8 +31,18 @@ export * from "./autodetect"; export * from "./tree-debug"; export * from "./project-parser"; -export * from "./scope"; -export * from "./add-import"; +export type {Scope} from "./scope"; +export {scopeOf, namesDeclaredIn, bindingNames} from "./scope"; +export type {QuoteChar, AddImportOptions} from "./add-import"; +export {ImportStyle, moduleNameOf, AddImport} from "./add-import"; +// AMD mechanics `recipes-ui5` builds on directly, beyond the `maybeBind` surface below. +export type {AmdBlock} from "./amd"; +export { + DEFAULT_AMD_CALLEES, amdBlockOf, dependencyNames, parameterNames, withDependency, withoutDependencyAt, + RemoveAmdDependency, removeNewlyUnusedAmdBindings +} from "./amd"; +export type {MaybeBindOptions, MaybeUnbindOptions, MaybeRebindOptions, ModuleBindings} from "./binding"; +export {maybeBind, maybeUnbind, maybeRebind, moduleBindings, isAmdBlock, maybeRemoveImport, maybeAddImport} from "./binding"; export * from "./remove-import"; export * from "./cleanup/index"; export * from "./recipes/index"; diff --git a/rewrite-javascript/rewrite/src/javascript/parser.ts b/rewrite-javascript/rewrite/src/javascript/parser.ts index 857c35e505..4c4a993232 100644 --- a/rewrite-javascript/rewrite/src/javascript/parser.ts +++ b/rewrite-javascript/rewrite/src/javascript/parser.ts @@ -54,6 +54,12 @@ import SpreadAttribute = JSX.SpreadAttribute; export interface JavaScriptParserOptions extends ParserOptions { styles?: NamedStyles[], sourceFileCache?: Map, + /** + * Type packages to load whose declarations nothing imports. Unset leaves TypeScript's default, + * which reads `@types/*` and nothing else; a package declaring its modules ambiently needs + * naming here for those declarations to be in scope. + */ + types?: string[], } function getScriptKindFromFileName(fileName: string): ts.ScriptKind { @@ -86,6 +92,7 @@ export class JavaScriptParser extends Parser { relativeTo, styles, sourceFileCache, + types, }: JavaScriptParserOptions = {}, ) { super({ctx, relativeTo}); @@ -108,7 +115,8 @@ export class JavaScriptParser extends Parser { emitDecoratorMetadata: true, forceConsistentCasingInFileNames: false, jsx: ts.JsxEmit.Preserve, - baseUrl: relativeTo || process.cwd() + baseUrl: relativeTo || process.cwd(), + ...(types ? {types} : {}) }; this.styles = styles; this.sourceFileCache = sourceFileCache; diff --git a/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts b/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts index e089a8e59b..2b5e05094e 100644 --- a/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts @@ -18,37 +18,9 @@ import { Option, Recipe } from "../../recipe"; import { TreeVisitor } from "../../visitor"; import { ExecutionContext } from "../../execution"; import { JavaScriptVisitor, JS } from "../index"; -import { maybeAddImport } from "../add-import"; -import { emptySpace, J, isIdentifier, rightPadded, singleSpace, Type } from "../../java"; -import { create as produce, Draft } from "mutative"; -import { randomId } from "../../uuid"; -import { emptyMarkers } from "../../markers"; - -/** - * Binds `member` under the name `local` already carries, so the file's references to it still - * resolve. `local` itself becomes the alias, keeping the type attribution it holds, and the alias - * takes its prefix: that whitespace separates the specifier from a `type` keyword before it. - */ -function aliasing(local: Draft, member: string): JS.Alias { - const propertyName: J.Identifier = { - id: randomId(), - kind: J.Kind.Identifier, - prefix: emptySpace, - markers: emptyMarkers, - annotations: [], - simpleName: member, - type: undefined, - fieldType: undefined - }; - return { - id: randomId(), - kind: JS.Kind.Alias, - prefix: local.prefix, - markers: emptyMarkers, - propertyName: rightPadded(propertyName, singleSpace), - alias: {...local, prefix: singleSpace} as J.Identifier - }; -} +import { maybeRebind } from "../binding"; +import { J, Type } from "../../java"; +import { create as produce } from "mutative"; /** * Changes an import from one module to another, updating all type attributions. @@ -113,19 +85,11 @@ export class ChangeImport extends Recipe { }) newMember?: string; - @Option({ - displayName: "New alias", - description: "Optional alias for the new import. Required when newMember is 'default' or '*'.", - required: false - }) - newAlias?: string; - constructor(options?: { oldModule?: string; oldMember?: string; newModule?: string; newMember?: string; - newAlias?: string; }) { super(options); } @@ -135,7 +99,6 @@ export class ChangeImport extends Recipe { const oldMember = this.oldMember; const newModule = this.newModule; const newMember = this.newMember ?? oldMember; - const newAlias = this.newAlias; // Build the old and new FQNs for type attribution updates const oldFqn = oldMember === 'default' || oldMember === '*' @@ -147,181 +110,14 @@ export class ChangeImport extends Recipe { return new class extends JavaScriptVisitor { private hasOldImport = false; - private oldAlias?: string; - private transformedImport = false; override async visitJsCompilationUnit(cu: JS.CompilationUnit, ctx: ExecutionContext): Promise { - // Reset tracking for each file - this.hasOldImport = false; - this.oldAlias = undefined; - this.transformedImport = false; - - // First pass: check if the old import exists and capture any alias - for (const statement of cu.statements) { - const stmt = statement.element ?? statement; - if (stmt.kind === JS.Kind.Import) { - const jsImport = stmt as JS.Import; - const aliasInfo = this.checkForOldImport(jsImport); - if (aliasInfo.found) { - this.hasOldImport = true; - this.oldAlias = aliasInfo.alias; - break; - } - } - } - - // Visit the compilation unit (this will transform imports via visitJsImport) - let result = await super.visitJsCompilationUnit(cu, ctx) as JS.CompilationUnit; - - // If we transformed an import but need to add to existing import from new module, - // or if we only removed a member from a multi-import, use maybeAddImport - if (this.hasOldImport && !this.transformedImport) { - const aliasToUse = newAlias ?? this.oldAlias; - - if (newMember === 'default') { - maybeAddImport(this, { - module: newModule, - member: 'default', - alias: aliasToUse, - onlyIfReferenced: false - }); - } else if (newMember === '*') { - maybeAddImport(this, { - module: newModule, - member: '*', - alias: aliasToUse, - onlyIfReferenced: false - }); - } else { - maybeAddImport(this, { - module: newModule, - member: newMember, - // A pinned alias is taken verbatim: `oldMember` is the name this - // import bound, which the file's references to it already read. - alias: aliasToUse ?? oldMember, - onlyIfReferenced: false - }); - } - } - - return result; - } - - override async visitImportDeclaration(jsImport: JS.Import, ctx: ExecutionContext): Promise { - let imp = await super.visitImportDeclaration(jsImport, ctx) as JS.Import; + this.hasOldImport = maybeRebind(this, { + from: {module: oldModule, member: oldMember}, + to: {module: newModule, member: newMember} + }) !== undefined; - if (!this.hasOldImport) { - return imp; - } - - const aliasInfo = this.checkForOldImport(imp); - if (!aliasInfo.found) { - return imp; - } - - // Check if this is the only import from the old module - const namedImports = this.getNamedImports(imp); - const isOnlyImport = namedImports.length === 1 || - (oldMember === 'default' && !imp.importClause?.namedBindings) || - (oldMember === '*'); - - if (isOnlyImport) { - // Transform the module specifier in place - this.transformedImport = true; - return produce(imp, draft => { - if (draft.moduleSpecifier) { - const literal = draft.moduleSpecifier.element as Draft; - literal.value = newModule; - // Update valueSource to preserve quote style - const originalSource = literal.valueSource || `"${oldModule}"`; - const quoteChar = originalSource.startsWith("'") ? "'" : '"'; - literal.valueSource = `${quoteChar}${newModule}${quoteChar}`; - } - // If we're also renaming the member, update the import specifier - if (newMember !== oldMember && oldMember !== 'default' && oldMember !== '*') { - const importClause = draft.importClause; - if (importClause?.namedBindings?.kind === JS.Kind.NamedImports) { - const namedImports = importClause.namedBindings as Draft; - for (const elem of namedImports.elements.elements) { - const specifier = elem.element; - if (specifier.specifier.kind === J.Kind.Identifier && - specifier.specifier.simpleName === oldMember) { - specifier.specifier = aliasing(specifier.specifier as Draft, newMember); - } else if (specifier.specifier.kind === JS.Kind.Alias) { - const aliasNode = specifier.specifier as Draft; - const propertyName = aliasNode.propertyName.element; - if (propertyName.kind === J.Kind.Identifier && - propertyName.simpleName === oldMember) { - propertyName.simpleName = newMember; - } - } - } - } - } - }); - } else { - // Remove just the specific member from the import - // maybeAddImport will add the new import - return this.removeNamedImportMember(imp, oldMember, ctx); - } - } - - private async removeNamedImportMember(imp: JS.Import, memberToRemove: string, _ctx: ExecutionContext): Promise { - return produce(imp, draft => { - const importClause = draft.importClause; - if (!importClause?.namedBindings) return; - if (importClause.namedBindings.kind !== JS.Kind.NamedImports) return; - - const namedImports = importClause.namedBindings as Draft; - const elements = namedImports.elements.elements; - const filteredElements = elements.filter(elem => { - const specifier = elem.element; - const specifierNode = specifier.specifier; - - if (specifierNode.kind === J.Kind.Identifier) { - return specifierNode.simpleName !== memberToRemove; - } - - if (specifierNode.kind === JS.Kind.Alias) { - const alias = specifierNode as JS.Alias; - const propertyName = alias.propertyName.element; - if (propertyName.kind === J.Kind.Identifier) { - return propertyName.simpleName !== memberToRemove; - } - } - - return true; - }); - - namedImports.elements.elements = filteredElements; - }); - } - - private getNamedImports(imp: JS.Import): string[] { - const imports: string[] = []; - const importClause = imp.importClause; - if (!importClause) return imports; - - const namedBindings = importClause.namedBindings; - if (!namedBindings || namedBindings.kind !== JS.Kind.NamedImports) return imports; - - const namedImports = namedBindings as JS.NamedImports; - for (const elem of namedImports.elements.elements) { - const specifier = elem.element; - const specifierNode = specifier.specifier; - - if (isIdentifier(specifierNode)) { - imports.push(specifierNode.simpleName); - } else if (specifierNode.kind === JS.Kind.Alias) { - const alias = specifierNode as JS.Alias; - const propertyName = alias.propertyName.element; - if (isIdentifier(propertyName)) { - imports.push(propertyName.simpleName); - } - } - } - - return imports; + return super.visitJsCompilationUnit(cu, ctx); } override async visitIdentifier(identifier: J.Identifier, ctx: ExecutionContext): Promise { @@ -653,79 +449,6 @@ export class ChangeImport extends Recipe { } return arrayType; } - - private checkForOldImport(jsImport: JS.Import): { found: boolean; alias?: string } { - // Check if this import is from the old module - const moduleSpecifier = jsImport.moduleSpecifier; - if (!moduleSpecifier) return { found: false }; - - const literal = moduleSpecifier.element; - if (literal.kind !== J.Kind.Literal) return { found: false }; - - const value = (literal as J.Literal).value; - if (value !== oldModule) return { found: false }; - - const importClause = jsImport.importClause; - if (!importClause) { - // Side-effect import - not what we're looking for - return { found: false }; - } - - // Check for default import - if (oldMember === 'default') { - if (importClause.name) { - const nameElem = importClause.name.element; - if (isIdentifier(nameElem)) { - return { found: true, alias: nameElem.simpleName }; - } - } - return { found: false }; - } - - // Check for namespace import - if (oldMember === '*') { - const namedBindings = importClause.namedBindings; - if (namedBindings?.kind === JS.Kind.Alias) { - const alias = namedBindings as JS.Alias; - if (isIdentifier(alias.alias)) { - return { found: true, alias: alias.alias.simpleName }; - } - } - return { found: false }; - } - - // Check for named imports - const namedBindings = importClause.namedBindings; - if (!namedBindings) return { found: false }; - - if (namedBindings.kind !== JS.Kind.NamedImports) return { found: false }; - - const namedImports = namedBindings as JS.NamedImports; - const elements = namedImports.elements.elements; - - for (const elem of elements) { - const specifier = elem.element; - const specifierNode = specifier.specifier; - - // Handle direct import: import { act } - if (isIdentifier(specifierNode) && specifierNode.simpleName === oldMember) { - return { found: true }; - } - - // Handle aliased import: import { act as something } - if (specifierNode.kind === JS.Kind.Alias) { - const alias = specifierNode as JS.Alias; - const propertyName = alias.propertyName.element; - if (isIdentifier(propertyName) && propertyName.simpleName === oldMember) { - if (isIdentifier(alias.alias)) { - return { found: true, alias: alias.alias.simpleName }; - } - } - } - } - - return { found: false }; - } }(); } } diff --git a/rewrite-javascript/rewrite/src/javascript/remove-import.ts b/rewrite-javascript/rewrite/src/javascript/remove-import.ts index 370712600e..52db7812eb 100644 --- a/rewrite-javascript/rewrite/src/javascript/remove-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/remove-import.ts @@ -5,41 +5,6 @@ import {JS, JSX} from "./tree"; import {mapAsync, updateIfChanged} from "../util"; import {ElementRemovalFormatter} from "../java"; -/** - * @param visitor The visitor to add the import removal to - * @param module The module name (e.g., 'fs', 'react') to remove imports from - * @param member Optionally, the specific member to remove from the import. - * If not specified, removes all unused imports from the module. - * Special values: - * - 'default': Removes the default import from the module if unused, - * regardless of its local name (e.g., `import React from 'react'`) - * - '*': Removes the namespace import if unused (e.g., `import * as fs from 'fs'`) - * - * @example - * // Remove a specific named import if unused - * maybeRemoveImport(visitor, 'fs', 'readFile'); - * - * @example - * // Remove the default import from 'react' if unused (regardless of local name) - * maybeRemoveImport(visitor, 'react', 'default'); - * - * @example - * // Remove all unused imports from 'react' module - * maybeRemoveImport(visitor, 'react'); - * - * @example - * // Remove namespace import if unused - * maybeRemoveImport(visitor, 'fs', '*'); - */ -export function maybeRemoveImport(visitor: JavaScriptVisitor, module: string, member?: string) { - for (const v of visitor.afterVisit || []) { - if (v instanceof RemoveImport && v.module === module && v.member === member) { - return; - } - } - visitor.afterVisit.push(new RemoveImport(module, member)); -} - // Type alias for RightPadded elements to simplify type signatures type RightPaddedElement = { element?: T; diff --git a/rewrite-javascript/rewrite/src/javascript/scope.ts b/rewrite-javascript/rewrite/src/javascript/scope.ts index 5c95ea0acb..30ff5de711 100644 --- a/rewrite-javascript/rewrite/src/javascript/scope.ts +++ b/rewrite-javascript/rewrite/src/javascript/scope.ts @@ -16,6 +16,8 @@ import {Cursor, isTree} from "../tree"; import {J} from "../java"; import {JS} from "./tree"; +// scope.ts sits below the visitor, so this stays type-only. +import type {JavaScriptVisitor} from "./visitor"; /** The names code at some position can reach unqualified. */ export interface Scope { @@ -43,7 +45,12 @@ export function scopeOf(cursor: Cursor): Scope { * shadow it at one of them. */ export function namesDeclaredIn(cu: JS.CompilationUnit): ReadonlySet { - const cached = declared.get(cu); + return namesDeclaredWithin(cu.statements, cu); +} + +/** As {@link namesDeclaredIn}, over one subtree, for a binding shared across only that much of a file. */ +export function namesDeclaredWithin(node: unknown, cacheKey: object = node as object): ReadonlySet { + const cached = declared.get(cacheKey); if (cached) { return cached; } @@ -61,9 +68,9 @@ export function namesDeclaredIn(cu: JS.CompilationUnit): ReadonlySet { } return false; }; - walk(cu.statements, collect); + walk(node, collect); - declared.set(cu, names); + declared.set(cacheKey, names); return names; } @@ -220,7 +227,7 @@ const blockScoped = new Set(['let', 'const', 'using']); // replaced one is walked afresh: every call site in a function asks what that body hoists, and every // import added to a file asks what that file declares. const hoisted = new WeakMap(); -const declared = new WeakMap>(); +const declared = new WeakMap>(); /** * The names blocks under `scope` hoist out to it. A `var` or function declaration reaches the whole @@ -268,7 +275,7 @@ function hoistedNames(scope: any): string[] { } /** Visits every LST node under `node`, leaving a subtree unvisited where `visit` returns false. */ -function walk(node: unknown, visit: (node: any) => boolean): void { +export function walk(node: unknown, visit: (node: any) => boolean): void { if (Array.isArray(node)) { node.forEach(child => walk(child, visit)); return; @@ -288,3 +295,44 @@ function walk(node: unknown, visit: (node: any) => boolean): void { function unwrap(node: any): any { return node?.kind === J.Kind.RightPadded || node?.kind === J.Kind.LeftPadded ? unwrap(node.element) : node; } + +/** `cursor` is protected on `TreeVisitor` and these APIs are free functions, so reaching it takes a cast. */ +export function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { + return (visitor as unknown as {cursor?: Cursor}).cursor; +} + +/** The compilation unit a cursor sits in, or the one a visitor is currently positioned in. */ +export function compilationUnitOf(from: Cursor | JavaScriptVisitor): JS.CompilationUnit | undefined { + const cursor = from instanceof Cursor ? from : cursorOf(from); + return cursor?.firstEnclosing((v): v is JS.CompilationUnit => v?.kind === JS.Kind.CompilationUnit); +} + +/** `preferred`, or the first `preferred_N` that `isTaken` rejects, so a new name never shadows one in scope. */ +export function deconflict(preferred: string, isTaken: (name: string) => boolean): string { + if (!isTaken(preferred)) { + return preferred; + } + for (let suffix = 1; ; suffix++) { + const candidate = `${preferred}_${suffix}`; + if (!isTaken(candidate)) { + return candidate; + } + } +} + +/** + * The `J.VariableDeclarations` a statement declares — itself for a bare `const x = …`, one per + * declarator for `const a = …, b = …`, which the parser wraps in a `JS.ScopedVariableDeclarations` + * instead. + */ +export function declarationsOf(statement: J | undefined): J.VariableDeclarations[] { + if (statement?.kind === J.Kind.VariableDeclarations) { + return [statement as J.VariableDeclarations]; + } + if (statement?.kind === JS.Kind.ScopedVariableDeclarations) { + return (statement as JS.ScopedVariableDeclarations).variables + .map(v => v.element) + .filter((v): v is J.VariableDeclarations => v?.kind === J.Kind.VariableDeclarations); + } + return []; +} diff --git a/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts b/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts index 56f396bd80..932624b62a 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts @@ -16,36 +16,6 @@ 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): 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 { - 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 diff --git a/rewrite-javascript/rewrite/src/javascript/templating/engine.ts b/rewrite-javascript/rewrite/src/javascript/templating/engine.ts index aa7c50313b..99cef891c1 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/engine.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/engine.ts @@ -28,6 +28,14 @@ import {isExpression, isStatement} from '../parser-utils'; import {randomId} from '../../uuid'; import ts from "typescript"; import {DependencyWorkspace} from "../dependency-workspace"; +import {ModuleScopeBinding, moduleScopeBindings} from '../add-import'; +import {walk} from '../scope'; +import {isIdentifier} from '../../java'; + +/** A module a template's context binds, and whether the parse resolved it well enough to attribute. */ +export interface ContextBinding extends ModuleScopeBinding { + attributed: boolean; +} import {Parameter} from "./types"; /** @@ -90,11 +98,13 @@ export function setTemplateSourceFileCache(cache?: Map): // from one compile to the next; see `JavaScriptParser.parse`. const templateParsers: Map = new Map(); -function templateParser(workspaceDir?: string): JavaScriptParser { - const key = workspaceDir ?? ""; +function templateParser(workspaceDir?: string, types?: string[]): JavaScriptParser { + // `types` changes what the compiler loads, so parsers cannot be shared across differing sets. + // An empty list loads nothing where absence loads the defaults, so the two encode apart. + const key = `${workspaceDir ?? ""}::${JSON.stringify(types ?? null)}`; let parser = templateParsers.get(key); if (!parser) { - parser = new JavaScriptParser({relativeTo: workspaceDir, sourceFileCache: templateSourceFileCache}); + parser = new JavaScriptParser({relativeTo: workspaceDir, sourceFileCache: templateSourceFileCache, types}); templateParsers.set(key, parser); } return parser; @@ -115,7 +125,8 @@ class TemplateCache { templateString: string, captures: (Capture | Any)[], contextStatements: string[], - dependencies: Record + dependencies: Record, + types: string[] | undefined ): string { // Use the actual template string (with placeholders) as the primary key const templateKey = templateString; @@ -129,7 +140,7 @@ class TemplateCache { // Dependencies const depsKey = JSON.stringify(dependencies || {}); - return `${templateKey}::${capturesKey}::${contextKey}::${depsKey}`; + return `${templateKey}::${capturesKey}::${contextKey}::${depsKey}::${JSON.stringify(types ?? null)}`; } /** @@ -139,9 +150,10 @@ class TemplateCache { templateString: string, captures: (Capture | Any)[], contextStatements: string[], - dependencies: Record + dependencies: Record, + types?: string[] ): Promise { - const key = this.generateKey(templateString, captures, contextStatements, dependencies); + const key = this.generateKey(templateString, captures, contextStatements, dependencies, types); let cu = this.cache.get(key); if (cu) { @@ -163,7 +175,7 @@ class TemplateCache { // Parse and cache (workspace only needed during parsing) // Use templateSourceFileCache if configured for ~3.2x speedup on dependency file parsing - const parser = templateParser(workspaceDir); + const parser = templateParser(workspaceDir, types); const parseGenerator = parser.parse({text: fullTemplateString, sourcePath: 'template.tsx'}); cu = (await parseGenerator.next()).value as JS.CompilationUnit; @@ -208,34 +220,62 @@ export class TemplateEngine { * @param dependencies NPM dependencies for type attribution * @returns A Promise resolving to the extracted template AST */ - static async getTemplateTree( + /** The template parsed with its context, which is what gives its code types to attribute against. */ + private static async parseWithContext( templateParts: TemplateStringsArray, parameters: Parameter[], - contextStatements: string[] = [], - dependencies: Record = {} - ): Promise { - // Generate type preamble for captures/parameters with types + contextStatements: string[], + dependencies: Record, + types: string[] | undefined + ): Promise { + // A capture's declared type reaches the parse as a declaration, so it belongs with context. const preamble = TemplateEngine.parameterPreamble(parameters); - - // Build the template string with parameter placeholders const templateString = TemplateEngine.buildTemplateString(templateParts, parameters); - - // Add preamble to context statements (so they're skipped during extraction) const contextWithPreamble = preamble.length > 0 ? [...contextStatements, ...preamble] : contextStatements; + return templateCache.getOrParse(templateString, [], contextWithPreamble, dependencies, types); + } - // Use cache to get or parse the compilation unit - const cu = await templateCache.getOrParse( - templateString, - [], - contextWithPreamble, - dependencies - ); + /** + * The modules the template's context binds, for the caller to bind in the file being edited. + * An `import` or `require` states one; anything else — a `declare`, a helper signature — types + * the template without asking for a binding. + */ + static async getContextBindings( + templateParts: TemplateStringsArray, + parameters: Parameter[], + contextStatements: string[] = [], + dependencies: Record = {}, + types?: string[] + ): Promise { + const cu = await TemplateEngine.parseWithContext(templateParts, parameters, contextStatements, dependencies, types); + // The template's own code is the last statement, so everything ahead of it is context. + const context = {...cu, statements: cu.statements.slice(0, -1)}; + const attributed = new Set(); + walk(context.statements, node => { + if (isIdentifier(node) && (node.type !== undefined || node.fieldType !== undefined)) { + attributed.add(node.simpleName); + } + return true; + }); + return moduleScopeBindings(context) + .filter(b => b.module !== undefined) + .map(b => ({...b, attributed: attributed.has(b.name)})); + } + + static async getTemplateTree( + templateParts: TemplateStringsArray, + parameters: Parameter[], + contextStatements: string[] = [], + dependencies: Record = {}, + types?: string[] + ): Promise { + const cu = await TemplateEngine.parseWithContext(templateParts, parameters, contextStatements, dependencies, types); // Check if there are any statements if (!cu.statements || cu.statements.length === 0) { - throw new Error(`Failed to parse template code (no statements):\n${templateString}`); + throw new Error(`Failed to parse template code (no statements):\n${TemplateEngine.buildTemplateString(templateParts, parameters)}`); } // The template code is always the last statement (after context + preamble) @@ -263,7 +303,7 @@ export class TemplateEngine { * @returns A Promise resolving to the generated AST node */ static async applyTemplateFromAst( - ast: JS.CompilationUnit, + ast: J, parameters: Parameter[], cursor: Cursor, coordinates: JavaCoordinates, @@ -490,7 +530,8 @@ export class TemplateEngine { templateParts: TemplateStringsArray, captures: (Capture | Any | RawCode)[], contextStatements: string[] = [], - dependencies: Record = {} + dependencies: Record = {}, + types?: string[] ): Promise { const preamble = TemplateEngine.capturePreamble(captures); @@ -531,7 +572,8 @@ export class TemplateEngine { templateString, actualCaptures, contextWithPreamble, - dependencies + dependencies, + types ); // Check if there are any statements diff --git a/rewrite-javascript/rewrite/src/javascript/templating/index.ts b/rewrite-javascript/rewrite/src/javascript/templating/index.ts index 376c751d2a..38ba5e0763 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/index.ts @@ -25,7 +25,6 @@ export type { MatchOptions, TemplateParameter, TemplateOptions, - ModuleBinding, RewriteRule, TryOnOptions, RewriteConfig, diff --git a/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts b/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts index 37f2f0f760..16225f6f15 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts @@ -236,7 +236,8 @@ export class Pattern { // A capture's type reaches the parse as a declaration, so it shapes the tree the same // way an explicit context statement does and belongs in the key alongside one. [...contextStatements, ...TemplateEngine.capturePreamble(this.captures)], - this._options.dependencies || {} + this._options.dependencies || {}, + this._options.types ); // Level 2: Global cache (fast path - shared with Template) @@ -247,7 +248,8 @@ export class Pattern { this.templateParts, this.captures, contextStatements, - this._options.dependencies || {} + this._options.dependencies || {}, + this._options.types ); globalAstCache.set(cacheKey, tree); } diff --git a/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts b/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts index 56ed9f6fe8..b555de0dc1 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts @@ -58,7 +58,15 @@ class RewriteRuleImpl implements RewriteRule { let result: J | undefined; const template = typeof this.after === 'function' ? this.after(match) : this.after; - const bindings = options?.bindings ?? (options?.visitor && template.resolveBindings(options.visitor)); + const bindings = options?.bindings ?? + (options?.visitor ? await template.resolveBindings(options.visitor) : undefined); + // Applying without them would splice the context's own names in unbound, which + // reads as a working edit and is not one. + if (bindings === undefined && await template.bindsModules()) { + throw new Error( + "Template binds modules in its context, so applying it needs their local names. " + + "Pass {visitor: this} to tryOn, or bindings you resolved yourself."); + } result = await template.apply(node, cursor, { values: match, format: this.format, bindings: bindings || undefined }); diff --git a/rewrite-javascript/rewrite/src/javascript/templating/template.ts b/rewrite-javascript/rewrite/src/javascript/templating/template.ts index fdc0a9c69e..f33b2dedb1 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/template.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/template.ts @@ -16,8 +16,8 @@ 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 {maybeBind} from '../binding'; +import {ContextBinding} from './engine'; import {JavaScriptVisitor} from '../visitor'; import {MatchResult} from './pattern'; import {generateCacheKey, globalAstCache, WRAPPERS_MAP_SYMBOL} from './utils'; @@ -177,6 +177,7 @@ export class TemplateBuilder { export class Template { private options: TemplateOptions = {}; private _cachedTemplate?: J; + private _contextBindings?: Promise; /** * Creates a new template. @@ -224,6 +225,7 @@ export class Template { this.options = {...this.options, ...options}; // Invalidate cache when configuration changes this._cachedTemplate = undefined; + this._contextBindings = undefined; return this; } @@ -240,20 +242,16 @@ export class Template { * @returns The cached or newly computed template tree * @internal */ - private async getTemplateTree(): Promise { + private async getTemplateTree(): Promise { // Level 1: Instance cache (fastest path) if (this._cachedTemplate) { - return this._cachedTemplate as JS.CompilationUnit; + return this._cachedTemplate; } // 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 || []), - ...Object.entries(this.options.bindings ?? {}) - .map(([name, b]) => bindingContextStatement(name, b, this.options.dependencies ?? {})) - ]; + const contextStatements = this.options.context || this.options.imports || []; const parametersKey = this.parameters.map((p, i) => { const value = p.value; // Include raw code values in the cache key using the symbol @@ -267,14 +265,15 @@ export class Template { parametersKey, // As in Pattern.getAstPattern: a parameter's type reaches the parse as a declaration [...contextStatements, ...TemplateEngine.parameterPreamble(this.parameters)], - this.options.dependencies || {} + this.options.dependencies || {}, + this.options.types ); // Level 2: Global cache (fast path - shared with Pattern) const cached = globalAstCache.get(cacheKey); if (cached) { - this._cachedTemplate = cached as JS.CompilationUnit; - return cached as JS.CompilationUnit; + this._cachedTemplate = cached; + return cached; } // Level 3: Compute via TemplateEngine (slow path) @@ -282,8 +281,9 @@ export class Template { this.templateParts, this.parameters, contextStatements, - this.options.dependencies || {} - ) as JS.CompilationUnit; + this.options.dependencies || {}, + this.options.types + ); // Cache in both levels globalAstCache.set(cacheKey, result); @@ -298,18 +298,47 @@ export class Template { * 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): Record { + async resolveBindings(visitor: JavaScriptVisitor): Promise> { const resolved: Record = {}; - 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}); + for (const binding of await this.contextBindings()) { + // Recognising the reference the template splices in takes attribution, so a module the + // workspace could not resolve is bound whether or not the template turns out to use it. + const onlyIfReferenced = binding.attributed; + const bound = maybeBind(visitor, { + module: binding.module!, + member: binding.member, + typeOnly: binding.typeOnly, + preferredName: binding.name, + onlyIfReferenced + }); + // An unresolved binding is left out rather than recorded as `undefined`, so `apply()`'s + // own "applied without a local name" check catches it, same as a caller-omitted one. + if (bound !== undefined) { + resolved[binding.name] = bound; + } } return resolved; } + /** Whether the context binds a module, which applying this template therefore has to resolve. */ + async bindsModules(): Promise { + return (await this.contextBindings()).length > 0; + } + + /** What this template's context statements bind, which is what it needs bound in the target file. */ + private contextBindings(): Promise { + return this._contextBindings ??= this.deriveContextBindings(); + } + + private deriveContextBindings(): Promise { + return TemplateEngine.getContextBindings( + this.templateParts, this.parameters, + this.options.context || this.options.imports || [], + this.options.dependencies || {}, + this.options.types + ); + } + /** * Applies this template and returns the resulting tree. * @@ -372,17 +401,21 @@ export class Template { } } - const declared = this.options.bindings ?? {}; const renames: Record = {}; const modules: Record = {}; - 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().`); + // Supplying names is what asks for the context's modules to be bound in the file being + // edited; without them a context import only types the template. + if (options?.bindings !== undefined) { + for (const binding of await this.contextBindings()) { + const bound = options.bindings[binding.name]; + if (bound === undefined) { + throw new Error(`Template binds '${binding.module}' in its context, but no local name was ` + + `given for '${binding.name}'. Either pass bindings from resolveBindings(visitor), or — if it ` + + `already did — binding was refused, which an AMD block or a file requiring its modules can do.`); + } + renames[binding.name] = bound; + modules[binding.name] = binding.module!; } - renames[name] = bound; - modules[name] = binding.module; } // Use instance-level cache to get the template tree diff --git a/rewrite-javascript/rewrite/src/javascript/templating/types.ts b/rewrite-javascript/rewrite/src/javascript/templating/types.ts index b0e3729c1b..0fe90d6177 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/types.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/types.ts @@ -18,7 +18,6 @@ import {J, Type} from '../../java'; import type {Pattern} from "./pattern"; import type {Template} from "./template"; import type {CaptureValue, RawCode} from "./capture"; -import type {AddImportOptions} from "../add-import"; import type {JavaScriptVisitor} from "../visitor"; /** @@ -350,6 +349,12 @@ export interface PatternOptions { */ dependencies?: Record; + /** + * Type packages to load whose declarations nothing imports, as {@link TemplateOptions.types}. + * A pattern matches on attribution, so a module typed only ambiently needs this to match. + */ + types?: string[]; + /** * When true, allows patterns without type annotations to match code with type annotations. * This enables more flexible pattern matching during development or when full type attribution @@ -485,27 +490,15 @@ export interface TemplateOptions { dependencies?: Record; /** - * Modules the template's code refers to, keyed by the identifier its source uses for each. - * The key is a preferred name: {@link Template.resolveBindings} deconflicts it against the - * file, and applying the template rewrites the template's references to whatever it settled on. + * Type packages to load whose declarations nothing imports. TypeScript reads `@types/*` on its + * own and everything else only when named here, so a package declaring its modules ambiently — + * rather than at a path matching the specifier — resolves only with this set. * - * @example - * ```typescript - * template`Theming.setTheme(${capture('theme')})` - * .configure({bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}}}) - * ``` + * @example `{dependencies: {'@sapui5/types': '^1.120.0'}, types: ['@sapui5/types']}` */ - bindings?: Record; + types?: string[]; } -/** - * A module a template's code depends on. The local name comes from the key it is declared under, - * and {@link Template.resolveBindings} settles the rest. `member` and `typeOnly` shape the import - * it creates, so a caller passing {@link ApplyOptions.bindings} of its own reads neither. - */ -export type ModuleBinding = Omit & { module: string }; - /** * Options for template application. */ diff --git a/rewrite-javascript/rewrite/src/javascript/templating/utils.ts b/rewrite-javascript/rewrite/src/javascript/templating/utils.ts index 334cd0916a..9cd7512d20 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/utils.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/utils.ts @@ -107,13 +107,15 @@ export function generateCacheKey( templateParts: string[] | TemplateStringsArray, itemsKey: string, contextStatements: string[], - dependencies: Record + dependencies: Record, + types?: string[] ): string { return [ Array.from(templateParts).join('|'), itemsKey, contextStatements.join(';'), - JSON.stringify(dependencies) + JSON.stringify(dependencies), + JSON.stringify(types ?? null) ].join('::'); } diff --git a/rewrite-javascript/rewrite/test/javascript/add-import.test.ts b/rewrite-javascript/rewrite/test/javascript/add-import.test.ts index a7d45238b1..100a584105 100644 --- a/rewrite-javascript/rewrite/test/javascript/add-import.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/add-import.test.ts @@ -2860,8 +2860,8 @@ describe('AddImport visitor', () => { const bound: string[] = []; spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { - bound.push(maybeAddImport(this, {module: 'react', member: 'useState', onlyIfReferenced: false})); - bound.push(maybeAddImport(this, {module: 'fs/promises', member: 'writeFile', onlyIfReferenced: false})); + bound.push(maybeAddImport(this, {module: 'react', member: 'useState', onlyIfReferenced: false})!); + bound.push(maybeAddImport(this, {module: 'fs/promises', member: 'writeFile', onlyIfReferenced: false})!); return super.visitJsCompilationUnit(cu, p); } }); @@ -2894,8 +2894,8 @@ describe('AddImport visitor', () => { const bound: string[] = []; spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { - bound.push(maybeAddImport(this, {module: 'm', member: 'join', onlyIfReferenced: false})); - bound.push(maybeAddImport(this, {module: 'n', member: 'shape', onlyIfReferenced: false})); + bound.push(maybeAddImport(this, {module: 'm', member: 'join', onlyIfReferenced: false})!); + bound.push(maybeAddImport(this, {module: 'n', member: 'shape', onlyIfReferenced: false})!); return super.visitJsCompilationUnit(cu, p); } }); @@ -2925,11 +2925,11 @@ describe('AddImport visitor', () => { const preferred: string[] = []; spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { - quoted.push(maybeAddImport(this, {module: 'fs', member: 'readFile', onlyIfReferenced: false})); - quoted.push(maybeAddImport(this, {module: 'fs', member: 'readFile', quoteStyle: '"', onlyIfReferenced: false})); + quoted.push(maybeAddImport(this, {module: 'fs', member: 'readFile', onlyIfReferenced: false})!); + quoted.push(maybeAddImport(this, {module: 'fs', member: 'readFile', quoteStyle: '"', onlyIfReferenced: false})!); - preferred.push(maybeAddImport(this, {module: 'theme', member: 'default', preferredName: 'Theming', onlyIfReferenced: false})); - preferred.push(maybeAddImport(this, {module: 'theme', member: 'default', preferredName: 'Th', onlyIfReferenced: false})); + preferred.push(maybeAddImport(this, {module: 'theme', member: 'default', preferredName: 'Theming', onlyIfReferenced: false})!); + preferred.push(maybeAddImport(this, {module: 'theme', member: 'default', preferredName: 'Th', onlyIfReferenced: false})!); return super.visitJsCompilationUnit(cu, p); } }); @@ -2954,7 +2954,7 @@ describe('AddImport visitor', () => { test('a name bound through any binding pattern is occupied without answering for the module', async () => { const spec = new RecipeSpec(); - const bound: string[] = []; + const bound: (string | undefined)[] = []; spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { bound.push(maybeAddImport(this, {module: 'm', member: 'b', onlyIfReferenced: false})); @@ -2967,7 +2967,7 @@ describe('AddImport visitor', () => { await spec.rewriteRun( typescript( ` - const {a: {b}} = require('other'); + const {a: {b}} = getOther(); const [e] = window; b(); @@ -2977,7 +2977,7 @@ describe('AddImport visitor', () => { import z from 'other'; import e_1 from 'p'; - const {a: {b}} = require('other'); + const {a: {b}} = getOther(); const [e] = window; b(); @@ -3020,7 +3020,7 @@ describe('AddImport visitor', () => { override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise { if ((method.name as J.Identifier)?.simpleName === 'anchor') { bound.push(maybeAddImport(this, - {module: `m${bound.length}`, member: 'merge', onlyIfReferenced: false})); + {module: `m${bound.length}`, member: 'merge', onlyIfReferenced: false})!); } return super.visitMethodInvocation(method, p); } @@ -3084,7 +3084,7 @@ describe('AddImport visitor', () => { override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise { if ((method.name as J.Identifier)?.simpleName === 'anchor') { bound.push(maybeAddImport(this, - {module: 'sap/base/util/merge', member: 'merge', onlyIfReferenced: false})); + {module: 'sap/base/util/merge', member: 'merge', onlyIfReferenced: false})!); } return super.visitMethodInvocation(method, p); } diff --git a/rewrite-javascript/rewrite/test/javascript/amd.test.ts b/rewrite-javascript/rewrite/test/javascript/amd.test.ts new file mode 100644 index 0000000000..16c3a0859a --- /dev/null +++ b/rewrite-javascript/rewrite/test/javascript/amd.test.ts @@ -0,0 +1,230 @@ +import {JavaScriptParser} from "../../src/javascript"; +import {J} from "../../src/java"; +import {JS} from "../../src/javascript"; +import {amdBlockOf, dependencyNames, parameterNames, withDependency, withoutDependencyAt} from "../../src/javascript/amd"; +import {fromVisitor, RecipeSpec} from "../../src/test"; +import {javascript, JavaScriptVisitor} from "../../src/javascript"; + +async function firstCall(source: string): Promise { + const parser = new JavaScriptParser(); + const gen = parser.parse({text: source, sourcePath: "a.js"}); + const parsed = (await gen.next()).value as JS.CompilationUnit; + const statement = parsed.statements[0].element; + return statement as J.MethodInvocation; +} + +describe("amdBlockOf", () => { + test("a named block with an export flag still pairs deps with parameters", async () => { + const call = await firstCall(`sap.ui.define("my/M", ["a/B", "c/D"], function (B, D) {}, true);`); + const block = amdBlockOf(call)!; + expect(dependencyNames(block)).toEqual(["a/B", "c/D"]); + expect(parameterNames(block)).toEqual(["B", "D"]); + }); + + test("a block written without a dependency array has an empty one", async () => { + const call = await firstCall(`sap.ui.define(function () {});`); + const block = amdBlockOf(call)!; + expect(block.dependenciesIndex).toBe(-1); + expect(dependencyNames(block)).toEqual([]); + }); + + test("an arrow factory is a factory", async () => { + const call = await firstCall(`define(["a/B"], (B) => {});`); + expect(parameterNames(amdBlockOf(call)!)).toEqual(["B"]); + }); + + test("a call that is not an AMD block is not one", async () => { + const call = await firstCall(`foo.bar(["a/B"], function (B) {});`); + expect(amdBlockOf(call)).toBeUndefined(); + }); + + test("a dependency the factory takes no parameter for reads as unbound", async () => { + const call = await firstCall(`define(["a/B", "c/D"], function (B) {});`); + expect(parameterNames(amdBlockOf(call)!)).toEqual(["B"]); + }); + + test("a callee written without a dot matches whatever the receiver", async () => { + const call = await firstCall(`foo.define(["a/B"], function (B) {});`); + expect(amdBlockOf(call)).toBeDefined(); + }); + + test("a dotted callee requires the whole namespaced path", async () => { + const namespaced = await firstCall(`sap.ui.define(["a/B"], function (B) {});`); + expect(amdBlockOf(namespaced, ["sap.ui.define"])).toBeDefined(); + expect(amdBlockOf(namespaced, ["sap.ui.other"])).toBeUndefined(); + + const bare = await firstCall(`define(["a/B"], function (B) {});`); + expect(amdBlockOf(bare, ["sap.ui.define"])).toBeUndefined(); + }); + + test("an empty dependency array parses as no dependencies, not one J.Empty", async () => { + const call = await firstCall(`define([], function () {});`); + expect(dependencyNames(amdBlockOf(call)!)).toEqual([]); + }); +}); + +function addDependency(module: string, binding: string) { + return new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + const block = amdBlockOf(m); + return block === undefined ? super.visitMethodInvocation(m, p) : + withDependency(m, block, module, binding) ?? m; + } + }; +} + +describe("withDependency", () => { + test("a one-per-line block keeps one per line", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define([\n "a/B"\n], function (\n B\n) {});`, + `sap.ui.define([\n "a/B",\n "c/D"\n], function (\n B,\n D\n) {});` + )); + }); + + test("an existing trailing comma moves to the entry that ends up last", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B",], function (B,) {});`, + `sap.ui.define(["a/B", "c/D",], function (B, D,) {});` + )); + }); + + test("a block with no dependency array gains one before the factory", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define(function () {});`, + `sap.ui.define(["c/D"], function (D) {});` + )); + }); + + test("an arrow factory keeps one per line same as a function factory", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define([\n "a/B"\n], (\n B\n) => {});`, + `sap.ui.define([\n "a/B",\n "c/D"\n], (\n B,\n D\n) => {});` + )); + }); + + test("an arrow factory's trailing comma moves to the entry that ends up last", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B",], (B,) => {});`, + `sap.ui.define(["a/B", "c/D",], (B, D,) => {});` + )); + }); + + test("an unparenthesized single-parameter arrow gains parens as it grows past one", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], B => {});`, + `sap.ui.define(["a/B", "c/D"], (B, D) => {});` + )); + }); + + test("refuses when the factory already binds fewer parameters than there are dependencies", async () => { + const call = await firstCall(`define(["a/B", "jquery"], function (B) {});`); + const block = amdBlockOf(call)!; + expect(withDependency(call, block, "c/D", "D")).toBeUndefined(); + }); + + test("refuses when the factory already binds more parameters than there are dependencies", async () => { + const call = await firstCall(`define(["a/B"], function (B, Extra) {});`); + const block = amdBlockOf(call)!; + expect(withDependency(call, block, "c/D", "D")).toBeUndefined(); + }); + + test("a trailing comment stays on the entry it followed instead of relabeling the appended one", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define([\n "a/B" // note\n], function (\n B\n) {});`, + `sap.ui.define([\n "a/B" // note\n,\n "c/D"], function (\n B,\n D\n) {});` + )); + }); +}); + +describe("withoutDependencyAt", () => { + test("removing the last entry hands the closing bracket's whitespace to its predecessor", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + const block = amdBlockOf(m); + return block === undefined ? super.visitMethodInvocation(m, p) : + withoutDependencyAt(m, block, 1); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define([\n "a/B",\n "c/D"\n], function (\n B,\n D\n) {});`, + `sap.ui.define([\n "a/B"\n], function (\n B\n) {});` + )); + }); + + test("removing the first entry hands its position to the one that takes its place", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + const block = amdBlockOf(m); + return block === undefined ? super.visitMethodInvocation(m, p) : + withoutDependencyAt(m, block, 0); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B", "c/D"], function (B, D) {});`, + `sap.ui.define(["c/D"], function (D) {});` + )); + }); + + test("removing the only entry leaves an empty array, not a hole", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + const block = amdBlockOf(m); + return block === undefined ? super.visitMethodInvocation(m, p) : + withoutDependencyAt(m, block, 0); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], function (B) {});`, + `sap.ui.define([], function () {});` + )); + }); + + test("an out-of-range index leaves the block unchanged instead of throwing", async () => { + const call = await firstCall(`define(["a/B", "c/D"], function (B, D) {});`); + const block = amdBlockOf(call)!; + expect(dependencyNames(amdBlockOf(withoutDependencyAt(call, block, 2))!)).toEqual(["a/B", "c/D"]); + }); + + function removeFirstDependency() { + return new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + const block = amdBlockOf(m); + return block === undefined ? super.visitMethodInvocation(m, p) : + withoutDependencyAt(m, block, 0); + } + }; + } + + test("removing the first entry moves no comment onto the survivor and drops none of its own", async () => { + const relabeled = new RecipeSpec(); + relabeled.recipe = fromVisitor(removeFirstDependency()); + await relabeled.rewriteRun(javascript( + `sap.ui.define([/* keep me */ "a/B", "c/D"], function (B, D) {});`, + `sap.ui.define(["c/D"], function (D) {});` + )); + + const dropped = new RecipeSpec(); + dropped.recipe = fromVisitor(removeFirstDependency()); + await dropped.rewriteRun(javascript( + `sap.ui.define(["a/B", /* about D */ "c/D"], function (B, D) {});`, + `sap.ui.define([/* about D */ "c/D"], function (D) {});` + )); + }); +}); diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts new file mode 100644 index 0000000000..9342c499e2 --- /dev/null +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -0,0 +1,1129 @@ +import {fromVisitor, RecipeSpec} from "../../src/test"; +import { + JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, maybeBind, + maybeAddImport, maybeUnbind, maybeRebind, maybeRemoveImport, removeNewlyUnusedAmdBindings +} from "../../src/javascript"; +import {emptySpace, J, rightPadded} from "../../src/java"; +import {emptyMarkers} from "../../src/markers"; +import {randomId} from "../../src/uuid"; +import {ExecutionContext} from "../../src"; + +function captureBindings(seen: {moduleSystem?: string, module?: string, binding?: string}, + localName: string = "Button", moduleName: string = "sap/m/Button") { + return captureModuleBindings(bindings => { + seen.moduleSystem = bindings.moduleSystem; + seen.module = bindings.moduleOf(localName); + seen.binding = bindings.bindingOf(moduleName); + }); +} + +/** Runs `extract` with the file's `ModuleBindings`, visited from the compilation unit itself. */ +function captureModuleBindings(extract: (bindings: ModuleBindings) => void) { + return new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + extract(moduleBindings(this)); + return super.visitJsCompilationUnit(cu, p); + } + }; +} + +describe("moduleBindings", () => { + test("an ESM default import binds its module", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string, module?: string, binding?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(typescript(`import Button from "sap/m/Button";`)); + expect(seen).toEqual({moduleSystem: "esm", module: "sap/m/Button", binding: "Button"}); + }); + + test("a plain script with no import, export, require, or AMD block reports \"none\"", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(typescript(`const x = 1;`)); + expect(seen.moduleSystem).toBe("none"); + }); + + test("no compilation unit on the cursor reports \"none\", not a lane it cannot know", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + constructor() { + super(); + seen.moduleSystem = moduleBindings(this).moduleSystem; + } + }); + await spec.rewriteRun(typescript(`const x = 1;`)); + expect(seen.moduleSystem).toBe("none"); + }); + + test("an export alone is enough to read as \"esm\", even with no import", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(typescript(`export const x = 1;`)); + expect(seen.moduleSystem).toBe("esm"); + }); + + test("a top-level await import is the file's only module syntax and reports \"esm\"", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(typescript(`const Button = await import("sap/m/Button");`)); + expect(seen.moduleSystem).toBe("esm"); + }); + + test("a selected require, unlike a bare one, does not read as a CommonJS binding", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(javascript(`const other = foo.require("a/Other");`)); + expect(seen.moduleSystem).toBe("none"); + }); + + test("a .cjs file reads as CommonJS even with no require call in it yet", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun({...javascript(`target();`), path: "module.cjs"}); + expect(seen.moduleSystem).toBe("commonjs"); + }); + + test("a .cts file reads as CommonJS even with no require call in it yet", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun({...typescript(`target();`), path: "module.cts"}); + expect(seen.moduleSystem).toBe("commonjs"); + }); + + test("a require among several declarators in one statement still reads as CommonJS", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(javascript(`const a = require("x"), b = require("y");`)); + expect(seen.moduleSystem).toBe("commonjs"); + }); + + test("an AMD block's parameters bind positionally to its dependencies", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string, module?: string, binding?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitReturn(ret: J.Return, p: any): Promise { + const bindings = moduleBindings(this); + seen.moduleSystem = bindings.moduleSystem; + seen.module = bindings.moduleOf("Button"); + seen.binding = bindings.bindingOf("sap/m/Button"); + return super.visitReturn(ret, p); + } + }); + await spec.rewriteRun(javascript(` + sap.ui.define(["sap/m/Button"], function (Button) { + return Button; + }); + `)); + expect(seen).toEqual({moduleSystem: "amd", module: "sap/m/Button", binding: "Button"}); + }); + + test("a named ESM import does not bind the module object", async () => { + const spec = new RecipeSpec(); + const seen: {module?: string, binding?: string} = {}; + spec.recipe = fromVisitor(captureModuleBindings(bindings => { + seen.module = bindings.moduleOf("getElementById"); + seen.binding = bindings.bindingOf("sap/ui/core/Element"); + })); + await spec.rewriteRun(typescript(`import {getElementById} from "sap/ui/core/Element";`)); + expect(seen).toEqual({module: undefined, binding: undefined}); + }); + + test("an ESM namespace import binds its module", async () => { + const spec = new RecipeSpec(); + const seen: {binding?: string} = {}; + spec.recipe = fromVisitor(captureModuleBindings(bindings => { + seen.binding = bindings.bindingOf("sap/ui/core/Element"); + })); + await spec.rewriteRun(typescript(`import * as Element from "sap/ui/core/Element";`)); + expect(seen.binding).toBe("Element"); + }); + + test("require binds the module object but not a destructured member", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string, elementBinding?: string, pathBinding?: string} = {}; + spec.recipe = fromVisitor(captureModuleBindings(bindings => { + seen.moduleSystem = bindings.moduleSystem; + seen.elementBinding = bindings.bindingOf("sap/ui/core/Element"); + seen.pathBinding = bindings.bindingOf("path"); + })); + await spec.rewriteRun(javascript(` + const Elem = require("sap/ui/core/Element"); + const {join} = require("path"); + `)); + expect(seen.moduleSystem).toBe("commonjs"); + expect(seen.elementBinding).toBe("Elem"); + + expect(seen.pathBinding).toBeUndefined(); + }); +}); + +describe("isAmdBlock", () => { + test("a define block inside an otherwise-ESM file is still a block", async () => { + const spec = new RecipeSpec(); + const blocks: string[] = []; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (isAmdBlock(m)) { + blocks.push(m.name.simpleName); + } + return super.visitMethodInvocation(m, p); + } + }); + await spec.rewriteRun(javascript(` + import x from "m"; + sap.ui.define(["a/B"], function (B) {}); + `)); + expect(blocks).toEqual(["define"]); + }); +}); + +function identifierRef(name: string): J.Identifier { + return { + kind: J.Kind.Identifier, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + annotations: [], + simpleName: name, + type: undefined, + fieldType: undefined + }; +} + +/** Rewrites `call` to read as `.call`, the way a real recipe uses the name `maybeBind` returned. */ +function withReference(call: J.MethodInvocation, name: string): J.MethodInvocation { + return {...call, select: rightPadded(identifierRef(name), emptySpace)}; +} + +/** A caller that uses the answer. */ +function rebind(module: string, bound: {name?: string}) { + return new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeBind(this, {module}); + return bound.name === undefined ? m : withReference(m, bound.name); + } + }; +} + +/** A caller that asks, then abandons: takes the name but never uses it. */ +function askAndAbandon(module: string, bound: {name?: string}) { + return new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeBind(this, {module}); + return m; + } + }; +} + +describe("maybeBind", () => { + test("AMD appends to both lists and reports the new name", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) { target(); });`, + `sap.ui.define(["sap/m/Button", "sap/ui/core/Element"], function (Button, Element) { Element.target(); });` + )); + expect(bound.name).toBe("Element"); + }); + + test("AMD accepts member: \"default\", which names the same whole module as no member at all", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeBind(this, {module: "sap/ui/core/Element", member: "default"}); + return bound.name === undefined ? m : withReference(m, bound.name); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) { target(); });`, + `sap.ui.define(["sap/m/Button", "sap/ui/core/Element"], function (Button, Element) { Element.target(); });` + )); + expect(bound.name).toBe("Element"); + }); + + test("AMD appends to an expression-bodied arrow factory same as a block-bodied one", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], (B) => target());`, + `sap.ui.define(["a/B", "sap/ui/core/Element"], (B, Element) => Element.target());` + )); + expect(bound.name).toBe("Element"); + }); + + test("AMD reuses an existing dependency's parameter and edits nothing on that lane", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/ui/core/Element"], function (Elem) { target(); });`, + `sap.ui.define(["sap/ui/core/Element"], function (Elem) { Elem.target(); });` + )); + expect(bound.name).toBe("Elem"); + }); + + test("AMD avoids a name the factory body declares", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { var Element = 1; target(); });`, + `sap.ui.define(["sap/ui/core/Element"], function (Element_1) { var Element = 1; Element_1.target(); });` + )); + expect(bound.name).toBe("Element_1"); + }); + + test("AMD avoids a name a destructuring pattern binds", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { const {Element} = window; target(); });`, + `sap.ui.define(["sap/ui/core/Element"], function (Element_1) { const {Element} = window; Element_1.target(); });` + )); + expect(bound.name).toBe("Element_1"); + }); + + test("AMD avoids a name a nested destructuring pattern binds", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Deep", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { const [{Deep}] = window; target(); });`, + `sap.ui.define(["sap/ui/core/Deep"], function (Deep_1) { const [{Deep}] = window; Deep_1.target(); });` + )); + expect(bound.name).toBe("Deep_1"); + }); + + test("AMD avoids a name a rest element binds", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { const [...Element] = window; target(); });`, + `sap.ui.define(["sap/ui/core/Element"], function (Element_1) { const [...Element] = window; Element_1.target(); });` + )); + expect(bound.name).toBe("Element_1"); + }); + + test("AMD refuses where a parameter would pair with the wrong dependency", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button", "x/Y"], function (Button) { target(); });` + )); + expect(bound.name).toBeUndefined(); + }); + + test("AMD refuses where a surplus parameter would pair with the new dependency", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button, Extra) { target(); });` + )); + expect(bound.name).toBeUndefined(); + }); + + test("a binding nothing goes on to reference is not written", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(askAndAbandon("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) { target(); });` + )); + expect(bound.name).toBe("Element"); + }); + + test("two calls for the same module at one block share the reservation", async () => { + const spec = new RecipeSpec(); + const bound1: {name?: string} = {}; + const bound2: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + const bound = bound1.name === undefined ? bound1 : bound2; + bound.name = maybeBind(this, {module: "sap/ui/core/Element"}); + return bound.name === undefined ? m : withReference(m, bound.name); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { target(); target(); });`, + `sap.ui.define(["sap/ui/core/Element"], function (Element) { Element.target(); Element.target(); });` + )); + expect(bound1.name).toBe("Element"); + expect(bound2.name).toBe("Element"); + }); + + test("two different modules whose preferred names collide deconflict against each other", async () => { + const spec = new RecipeSpec(); + const boundA: {name?: string} = {}; + const boundB: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName === "targetA") { + boundA.name = maybeBind(this, {module: "a/Element"}); + return boundA.name === undefined ? m : withReference(m, boundA.name); + } + if (m.name.simpleName === "targetB") { + boundB.name = maybeBind(this, {module: "b/Element"}); + return boundB.name === undefined ? m : withReference(m, boundB.name); + } + return super.visitMethodInvocation(m, p); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { targetA(); targetB(); });`, + `sap.ui.define(["a/Element", "b/Element"], function (Element, Element_1) { Element.targetA(); Element_1.targetB(); });` + )); + expect(boundA.name).toBe("Element"); + expect(boundB.name).toBe("Element_1"); + }); + + test("a block replaced before the deferred edit runs is an error, not a silent skip", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName === "target") { + bound.name = maybeBind(this, {module: "sap/ui/core/Element"}); + return m; + } + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + // Simulate an unrelated edit that replaces the block's own node identity, so the + // id the deferred edit was queued against no longer exists anywhere in the tree. + return isAmdBlock(visited) ? {...visited, id: randomId()} : visited; + } + }); + await expect(spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) { target(); });` + ))).rejects.toThrow(/No AMD block/); + }); + + test("a parameter dropped elsewhere in the same visit rejects the queued dependency", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName === "target") { + const name = maybeBind(this, {module: "sap/ui/core/Element"}); + return name === undefined ? m : withReference(m, name); + } + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + if (!isAmdBlock(visited)) { + return visited; + } + // Simulate an unrelated edit in the same visit that drops the factory's only + // parameter without removing the dependency it was paired to, reopening the gap + // bindAmd's own check had already cleared. + const factoryArg = visited.arguments.elements[1].element as JS.StatementExpression; + const method = factoryArg.statement as J.MethodDeclaration; + const empty: J.Empty = {kind: J.Kind.Empty, id: randomId(), prefix: emptySpace, markers: emptyMarkers}; + const stripped: J.MethodDeclaration = { + ...method, + parameters: {...method.parameters, elements: [rightPadded(empty, emptySpace)]} + }; + const strippedFactory: JS.StatementExpression = {...factoryArg, statement: stripped}; + const args = [...visited.arguments.elements]; + args[1] = {...args[1], element: strippedFactory}; + const withStrippedFactory: J.MethodInvocation = {...visited, arguments: {...visited.arguments, elements: args}}; + return withStrippedFactory; + } + }); + await expect(spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], function (B) { target(); });` + ))).rejects.toThrow(/no longer has matching dependency and parameter counts/); + }); + + test("ESM creates a default import and reports its name", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(typescript( + `target();`, + `import Element from 'sap/ui/core/Element';\n\nElement.target();` + )); + expect(bound.name).toBe("Element"); + }); + + test("a bare string is shorthand for {module}", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeBind(this, "sap/ui/core/Element"); + return bound.name === undefined ? m : withReference(m, bound.name); + } + }); + await spec.rewriteRun(typescript( + `target();`, + `import Element from 'sap/ui/core/Element';\n\nElement.target();` + )); + expect(bound.name).toBe("Element"); + }); + + test("refuses rather than derive an illegal identifier from the module's last segment", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.name = maybeBind(this, "lodash-es"); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript(`const x = 1;`)); + expect(bound.name).toBeUndefined(); + }); + + test("refuses rather than derive a reserved word from the module's last segment", async () => { + const hardKeyword = new RecipeSpec(); + const hardKeywordBound: {name?: string} = {}; + hardKeyword.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + hardKeywordBound.name = maybeBind(this, "a/class"); + return super.visitJsCompilationUnit(cu, p); + } + }); + await hardKeyword.rewriteRun(typescript(`const x = 1;`)); + expect(hardKeywordBound.name).toBeUndefined(); + + const contextualKeyword = new RecipeSpec(); + const contextualKeywordBound: {name?: string} = {}; + contextualKeyword.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + contextualKeywordBound.name = maybeBind(this, "a/await"); + return super.visitJsCompilationUnit(cu, p); + } + }); + await contextualKeyword.rewriteRun(typescript(`const x = 1;`)); + expect(contextualKeywordBound.name).toBeUndefined(); + }); + + test("ESM reuses a default import bound under another name", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(typescript( + `import Elem from "sap/ui/core/Element";\n\ntarget();`, + `import Elem from "sap/ui/core/Element";\n\nElem.target();` + )); + expect(bound.name).toBe("Elem"); + }); + + test("a namespace import answers a namespace request but not a plain module request", async () => { + const namespaceRequest = new RecipeSpec(); + const namespaceName: {value?: string} = {}; + namespaceRequest.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + namespaceName.value = maybeBind(this, {module: "sap/m/Button", member: "*"}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await namespaceRequest.rewriteRun(typescript( + `import * as Button from "sap/m/Button";\n\ntarget();` + )); + + const bareRequest = new RecipeSpec(); + const bound: {name?: string} = {}; + bareRequest.recipe = fromVisitor(rebind("sap/m/Button", bound)); + await bareRequest.rewriteRun(typescript( + `import * as Button from "sap/m/Button";\n\ntarget();`, + `import * as Button from "sap/m/Button";\nimport Button_1 from "sap/m/Button";\n\nButton_1.target();` + )); + + expect(namespaceName.value).toBe("Button"); + expect(bound.name).toBe("Button_1"); + }); + + test("a require binding answers a namespace request too, since CommonJS has no default/namespace split", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.name = maybeBind(this, {module: "sap/ui/core/Element", member: "*"}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(javascript( + `const Elem = require("sap/ui/core/Element");\n\ntarget();` + )); + expect(bound.name).toBe("Elem"); + }); + + test("a whole-module await import answers a namespace request but not a plain module request", async () => { + const namespaceRequest = new RecipeSpec(); + const namespaceName: {value?: string} = {}; + namespaceRequest.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + namespaceName.value = maybeBind(this, {module: "sap/m/Button", member: "*"}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await namespaceRequest.rewriteRun(typescript( + `const Button = await import("sap/m/Button");\n\ntarget();` + )); + + const bareRequest = new RecipeSpec(); + const bound: {name?: string} = {}; + bareRequest.recipe = fromVisitor(rebind("sap/m/Button", bound)); + await bareRequest.rewriteRun(typescript( + `const Button = await import("sap/m/Button");\n\ntarget();`, + `import Button_1 from "sap/m/Button";\n\nconst Button = await import("sap/m/Button");\n\nButton_1.target();` + )); + + expect(namespaceName.value).toBe("Button"); + expect(bound.name).toBe("Button_1"); + }); + + test("a destructured await import answers neither request but still occupies its name", async () => { + const spec = new RecipeSpec(); + const bound: (string | undefined)[] = []; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.push(maybeBind(this, {module: "sap/m/Button", onlyIfReferenced: false})); + bound.push(maybeBind(this, {module: "sap/m/Button", member: "*", onlyIfReferenced: false})); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `const {Button} = await import("sap/m/Button");\n\ntarget();`, + `import Button_1 from "sap/m/Button";\nimport * as Button_2 from "sap/m/Button";\n\nconst {Button} = await import("sap/m/Button");\n\ntarget();` + )); + expect(bound).toEqual(["Button_1", "Button_2"]); + }); + + test("a CommonJS file answers with the binding its require already has", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `const Elem = require("sap/ui/core/Element");\n\ntarget();`, + `const Elem = require("sap/ui/core/Element");\n\nElem.target();` + )); + expect(bound.name).toBe("Elem"); + }); + + test("a CommonJS file refuses rather than gain an import", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `const other = require("a/Other");\n\ntarget();` + )); + expect(bound.name).toBeUndefined(); + }); + + test("a member request refuses on a CommonJS file but still creates on an ESM file", async () => { + const memberBind = (bound: {value?: string}) => new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.value = maybeBind(this, {module: "fs", member: "readFile", onlyIfReferenced: false}); + return super.visitJsCompilationUnit(cu, p); + } + }; + + const onCommonJs = new RecipeSpec(); + const commonJsName: {value?: string} = {}; + onCommonJs.recipe = fromVisitor(memberBind(commonJsName)); + await onCommonJs.rewriteRun(javascript( + `const other = require("a/Other");\n\ntarget();` + )); + + const onEsm = new RecipeSpec(); + const esmName: {value?: string} = {}; + onEsm.recipe = fromVisitor(memberBind(esmName)); + await onEsm.rewriteRun(typescript( + `target();`, + `import {readFile} from 'fs';\n\ntarget();` + )); + + expect(commonJsName.value).toBeUndefined(); + expect(esmName.value).toBe("readFile"); + }); + + test("a .mjs file with a require call still creates an import", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun({ + ...javascript( + `const other = require("a/Other");\n\ntarget();`, + `import Element from "sap/ui/core/Element";\n\nconst other = require("a/Other");\n\nElement.target();` + ), + path: "module.mjs" + }); + expect(bound.name).toBe("Element"); + }); + + test("a nested require callback is the block a binding belongs to", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], function (B) { sap.ui.require([], function () { target(); }); });`, + `sap.ui.define(["a/B"], function (B) { sap.ui.require(["sap/ui/core/Element"], function (Element) { Element.target(); }); });` + )); + expect(bound.name).toBe("Element"); + }); + + test("the deprecated maybeAddImport still works, returning what the equivalent maybeBind call would", async () => { + const bindWith = (bind: (visitor: JavaScriptVisitor) => string | undefined) => { + const bound: {name?: string} = {}; + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName === "target") { + bound.name = bind(this); + } + return super.visitMethodInvocation(m, p); + } + }); + return spec.rewriteRun(typescript( + `target();`, + `import {readFile} from 'fs';\n\ntarget();` + )).then(() => bound.name); + }; + + const viaAddImport = await bindWith(v => maybeAddImport(v, {module: "fs", member: "readFile", onlyIfReferenced: false})); + const viaBind = await bindWith(v => maybeBind(v, {module: "fs", member: "readFile", onlyIfReferenced: false})); + expect(viaAddImport).toBe("readFile"); + expect(viaBind).toBe("readFile"); + }); +}); + + test("a type-only import does not answer a request for a value", async () => { + // The name a type-only import binds erases, so reusing it would emit an unbound reference. + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("m", bound)); + await spec.rewriteRun(typescript( + `import type X from "m";\ntarget();`, + `import type X from "m";\nimport m from "m";\nm.target();`)); + expect(bound.name).toBe("m"); + }); + + test("a file that exports is a module, whatever else it requires", async () => { + // `export` settles the module system, so a `require` alongside it does not make the file + // CommonJS and does not block creating an import. + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("m", bound)); + await spec.rewriteRun(typescript( + `export function f() {}\nconst p = require("path");\ntarget();`, + `import m from "m";\n\nexport function f() {}\nconst p = require("path");\nm.target();`)); + expect(bound.name).toBe("m"); + }); + + test("an alias is bound verbatim or not at all", async () => { + // A caller naming an alias may already have emitted code using it, so another name for + // the same module does not answer the request and a deconflicted spelling is refused. + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeBind(this, {module: "m", alias: "Pinned"}); + return bound.name === undefined ? m : withReference(m, bound.name); + } + }); + await spec.rewriteRun(typescript( + `import Y from "m";\ntarget();`, + `import Y from "m";\nimport Pinned from "m";\nPinned.target();`)); + expect(bound.name).toBe("Pinned"); + }); + + test("a factory parameter clears every name the factory declares, not only those in scope", async () => { + // The parameter is visible across the whole body, and the queue hands its name to later + // requests at cursors inside it, so a name declared in a nested scope still shadows it. + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("a/Theming", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { target(); function f() { var Theming = 1; return Theming; } });`, + `sap.ui.define(["a/Theming"], function (Theming_1) { Theming_1.target(); function f() { var Theming = 1; return Theming; } });`)); + expect(bound.name).toBe("Theming_1"); + }); + +describe("maybeRebind", () => { + test("an ESM member move keeps the local name", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.name = maybeRebind(this, { + from: {module: "lodash", member: "extend"}, + to: {module: "lodash", member: "assign"} + }); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import { extend } from "lodash";\n\nextend({}, {});`, + `import { assign as extend } from "lodash";\n\nextend({}, {});` + )); + expect(bound.name).toBe("extend"); + }); + + test("an AMD dependency swap keeps the parameter and its index", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeRebind(this, {from: {module: "a/Old"}, to: {module: "a/New"}}); + return m; + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B", "a/Old", "a/C"], function (B, Old, C) { target(); });`, + `sap.ui.define(["a/B", "a/New", "a/C"], function (B, Old, C) { target(); });` + )); + expect(bound.name).toBe("Old"); + }); + + test("a rebind of a module nothing binds returns undefined and changes nothing", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.name = maybeRebind(this, {from: {module: "nope"}, to: {module: "also-nope"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript(`const x = 1;`)); + expect(bound.name).toBeUndefined(); + }); + + test("a member rebind refuses on AMD, since a factory parameter cannot bind one", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeRebind(this, {from: {module: "a/Old", member: "x"}, to: {module: "a/New"}}); + return m; + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/Old"], function (Old) { target(); });` + )); + expect(bound.name).toBeUndefined(); + }); + + test("moving the default out of a mixed default+named import keeps the named siblings", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRebind(this, {from: {module: "react"}, to: {module: "preact"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import React, {useState, useMemo} from "react";\n\nReact.foo();\nuseState();\nuseMemo();`, + `import {useState, useMemo} from "react";\nimport React from "preact";\n\nReact.foo();\nuseState();\nuseMemo();` + )); + }); + + test("moving the namespace out of a mixed default+namespace import keeps the default", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRebind(this, {from: {module: "m", member: "*"}, to: {module: "m2", member: "*"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import D, * as N from "m";\n\nD();\nN.foo();`, + `import D from "m";\nimport * as N from "m2";\n\nD();\nN.foo();` + )); + }); + + test("the created replacement stays type-only, and the surviving specifier keeps its own formatting", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRebind(this, {from: {module: "m", member: "a"}, to: {module: "m2", member: "a"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import type { a, b} from "m";\n\nlet x: a;\nlet y: b;`, + `import type { b} from "m";\nimport type {a} from "m2";\n\nlet x: a;\nlet y: b;` + )); + }); + + test("a rebind that would change default/named/namespace shape refuses, since the only edit available is in place", async () => { + const namedToDefault = new RecipeSpec(); + const namedToDefaultBound: {name?: string} = {}; + namedToDefault.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + namedToDefaultBound.name = maybeRebind(this, {from: {module: "old", member: "act"}, to: {module: "new", member: "default"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await namedToDefault.rewriteRun(typescript(`import {act} from "old";\n\nact();`)); + expect(namedToDefaultBound.name).toBeUndefined(); + + const defaultToNamed = new RecipeSpec(); + const defaultToNamedBound: {name?: string} = {}; + defaultToNamed.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + defaultToNamedBound.name = maybeRebind(this, {from: {module: "old"}, to: {module: "new", member: "act"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await defaultToNamed.rewriteRun(typescript(`import D from "old";\n\nD();`)); + expect(defaultToNamedBound.name).toBeUndefined(); + }); + + test("a moved specifier's own inline type marker survives even where the clause it left is not type-only", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRebind(this, {from: {module: "m", member: "a"}, to: {module: "m2", member: "a"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import {type a, b} from "m";\n\nlet x: a;\nlet y: b;`, + `import {b} from "m";\nimport type {a} from "m2";\n\nlet x: a;\nlet y: b;` + )); + }); +}); + +function dropModule(module: string, member?: string) { + return new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeUnbind(this, {module, member}); + return super.visitJsCompilationUnit(cu, p); + } + }; +} + +describe("maybeUnbind on an AMD block", () => { + test("an unreferenced dependency goes with its parameter", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(dropModule("sap/m/Button")); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button", "a/C"], function (Button, C) { C.f(); });`, + `sap.ui.define(["a/C"], function (C) { C.f(); });` + )); + }); + + test("a dependency the body still names stays", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(dropModule("sap/m/Button")); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) { Button.f(); });` + )); + }); + + test("a dependency bound to no parameter is left alone", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(dropModule("a/Side")); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/Side"], function () {});` + )); + }); + + test("a member-scoped request does not apply, since a dependency binds a whole module", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(dropModule("sap/m/Button", "default")); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) {});` + )); + }); + + test("a custom amdCallee reaches the removal lane", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeUnbind(this, {module: "sap/m/Button", amdCallee: "myDefine"}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(javascript( + `myDefine(["sap/m/Button", "a/C"], function (Button, C) { C.f(); });`, + `myDefine(["a/C"], function (C) { C.f(); });` + )); + }); + + test("two calls differing only in amdCallee each queue their own removal", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeUnbind(this, {module: "a/B", amdCallee: "define"}); + maybeUnbind(this, {module: "a/B", amdCallee: "myDefine"}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(javascript( + `define(["a/B", "a/C"], function (B, C) { C.f(); });\nmyDefine(["a/B", "a/D"], function (B, D) { D.f(); });`, + `define(["a/C"], function (C) { C.f(); });\nmyDefine(["a/D"], function (D) { D.f(); });` + )); + }); +}); + +describe("maybeUnbind on an ESM file", () => { + test("removes the import; the AMD lane it also queues finds no block to act on", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(dropModule("sap/m/Button")); + await spec.rewriteRun(typescript( + `import Button from "sap/m/Button";\n\nconsole.log(1);`, + `console.log(1);` + )); + }); + + test("a bare string is shorthand for {module}", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeUnbind(this, "sap/m/Button"); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import Button from "sap/m/Button";\n\nconsole.log(1);`, + `console.log(1);` + )); + }); + + test("the deprecated maybeRemoveImport still works, doing what the equivalent maybeUnbind call would", async () => { + const removeWith = (remove: (visitor: JavaScriptVisitor) => void) => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + remove(this); + return super.visitJsCompilationUnit(cu, p); + } + }); + return spec.rewriteRun(typescript( + `import Button from "sap/m/Button";\n\nconsole.log(1);`, + `console.log(1);` + )); + }; + + await removeWith(v => maybeRemoveImport(v, "sap/m/Button")); + await removeWith(v => maybeUnbind(v, {module: "sap/m/Button"})); + }); + + test("a block with no dependency array keeps its loader parameters", async () => { + // `define(factory)` takes `require`, `exports` and `module` from the loader by position, + // so dropping one there shifts what the rest receive. + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(renameThenSweep({exports: "elsewhere"})); + await spec.rewriteRun(javascript( + `define(function (require, exports, module) { exports.x(); });`, + `define(function (require, exports, module) { elsewhere.x(); });`)); + }); + + test("two calls for one move are one rebind", async () => { + // Each matched reference asks, but the block is rewritten once: a second queued visitor + // would find the dependency already moved and fail the recipe. + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + maybeRebind(this, {from: {module: "a/Old"}, to: {module: "a/New"}}); + return m; + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/Old"], function (Old) { target(); target(); });`, + `sap.ui.define(["a/New"], function (Old) { target(); target(); });`)); + }); + + test("moving the only named member out leaves no empty braces", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRebind(this, {from: {module: "m", member: "a"}, to: {module: "m2", member: "a"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import D, {a} from "m";\na();`, + `import D from "m";\nimport {a} from "m2";\na();`)); + }); +}); + +/** Renames a call's `select` identifier per `renames`, then sweeps whatever the rename orphaned. */ +function renameThenSweep(renames: Record) { + return new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, ctx: ExecutionContext): Promise { + const rewritten = await super.visitJsCompilationUnit(cu, ctx) as JS.CompilationUnit; + return removeNewlyUnusedAmdBindings(cu, rewritten, ctx); + } + + override async visitMethodInvocation(m: J.MethodInvocation, ctx: ExecutionContext): Promise { + const select = m.select?.element; + const to = select?.kind === J.Kind.Identifier ? renames[(select as J.Identifier).simpleName] : undefined; + if (to === undefined) { + return super.visitMethodInvocation(m, ctx); + } + const renamedSelect: J.Identifier = {...(select as J.Identifier), simpleName: to}; + const renamed: J.MethodInvocation = {...m, select: {...m.select!, element: renamedSelect}}; + return renamed; + } + }; +} + +describe("removeNewlyUnusedAmdBindings", () => { + test("a binding a rewrite stopped using goes", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(renameThenSweep({Old: "kept"})); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/Old", "a/Kept"], function (Old, kept) { Old.f(); });`, + `sap.ui.define(["a/Kept"], function (kept) { kept.f(); });` + )); + }); + + test("a binding that was already unused stays, being loaded for its side effects", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(renameThenSweep({Old: "kept"})); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/Side", "a/Kept"], function (side, kept) { kept.f(); });` + )); + }); + + test("removing two non-adjacent bindings still pairs each survivor with its own dependency", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(renameThenSweep({B: "A", D: "C"})); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/A", "a/B", "a/C", "a/D"], function (A, B, C, D) { A.f(); B.f(); C.f(); D.f(); });`, + `sap.ui.define(["a/A", "a/C"], function (A, C) { A.f(); A.f(); C.f(); C.f(); });` + )); + }); +}); diff --git a/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts b/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts index 50926781e8..4363249c3c 100644 --- a/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts @@ -29,7 +29,7 @@ import { typescript } from "../../../src/javascript"; import {DependencyWorkspace} from "../../../src/javascript/dependency-workspace"; -import {J} from "../../../src/java"; +import {J, Type} from "../../../src/java"; import {fromVisitor, RecipeSpec} from "../../../src/test"; import * as path from "path"; import * as os from "os"; @@ -76,6 +76,81 @@ describe('template dependencies integration', () => { expect(foundMatch).toBe(true); }, 60000); + test('`types` decides whether a pattern matches, where matching is strict', async () => { + // A pattern matches on attribution, so the declarations it was parsed against decide it. + // Lenient matching — the default — succeeds either way, which is what makes it lenient. + const workspaceDir = await DependencyWorkspace.getOrCreateWorkspace({dependencies: {'@types/node': '^20.0.0'}}); + const parser = new JavaScriptParser({relativeTo: workspaceDir, types: ['node']}); + const parseGen = parser.parse({text: `process.cwd();`, sourcePath: 'test.ts'}); + const cu = (await parseGen.next()).value; + + const matchesWith = async (types: string[]) => { + const pat = pattern`process.cwd()`.configure({ + dependencies: {'@types/node': '^20.0.0'}, + types, + lenientTypeMatching: false + }); + let matched = false; + await (new class extends JavaScriptVisitor { + override async visitMethodInvocation(method: J.MethodInvocation, _p: any): Promise { + if (method.name.simpleName === 'cwd' && await pat.match(method, this.cursor)) { + matched = true; + } + return method; + } + }).visit(cu, undefined); + return matched; + }; + + expect(await matchesWith(['node'])).toBe(true); + + expect(await matchesWith([])).toBe(false); + }, 120000); + + test('`types` decides which declarations the template parse loads', async () => { + // `process` is a global `@types/node` declares, reachable only through automatic type + // inclusion — unlike a module specifier, which resolves by path whatever this option says. + // An explicit list replaces the default rather than extending it, so `[]` loads nothing. + const processDeclaringTypeWith = async (types: string[]) => { + const tmpl = template`process.cwd()`.configure({ + dependencies: {'@types/node': '^20.0.0'}, + types + }); + + const parser = new JavaScriptParser(); + const parseGen = parser.parse({text: `const x = 1;`, sourcePath: 'test.ts'}); + const cu = (await parseGen.next()).value; + + let applied: J | undefined; + await (new class extends JavaScriptVisitor { + override async visitVariable(variable: any, _p: any): Promise { + applied ??= await tmpl.apply(variable, this.cursor, {values: new Map()}); + return variable; + } + }).visit(cu, undefined); + + let methodType: Type.Method | undefined; + await (new class extends JavaScriptVisitor { + override async visitMethodInvocation(method: J.MethodInvocation, _p: any): Promise { + if (method.name.simpleName === 'cwd') { + methodType ??= method.methodType; + } + return method; + } + }).visit(applied!, undefined); + // `name` is the callee's own spelling whether or not anything resolved, so the + // declaring type is what says the declarations were loaded. + const declaring = methodType?.declaringType; + return declaring && Type.isClass(declaring) + ? (declaring as Type.Class).fullyQualifiedName + : undefined; + }; + + expect(await processDeclaringTypeWith(['node'])).toBe('global.NodeJS.Process'); + + expect(await processDeclaringTypeWith([])).toBeUndefined(); + }, 120000); + test('template with dependencies generates AST with type attribution', async () => { // Create a template with dependencies const tmpl = template`v1()`.configure({ diff --git a/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts b/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts index 6b3e0fcd99..fa21a07f61 100644 --- a/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts @@ -16,7 +16,6 @@ import {fromVisitor, RecipeSpec} from "../../../src/test"; import {capture, JavaScriptVisitor, pattern, rewrite, template, typescript} from "../../../src/javascript"; import {J} from "../../../src/java"; -import {bindingContextStatement} from "../../../src/javascript/templating/bindings"; describe('templates that declare module bindings', () => { const spec = new RecipeSpec(); @@ -35,7 +34,7 @@ describe('templates that declare module bindings', () => { test('the bound name is deconflicted and the template follows it', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`Theming.setTheme(${arg})`.configure({ - bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}} + context: [`import Theming from 'sap/ui/core/Theming';`] }), arg); await spec.rewriteRun( @@ -50,7 +49,7 @@ describe('templates that declare module bindings', () => { test('an import already binding the module is reused, under the name it already has', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`Theming.setTheme(${arg})`.configure({ - bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}} + context: [`import Theming from 'sap/ui/core/Theming';`] }), arg); await spec.rewriteRun( @@ -65,10 +64,10 @@ describe('templates that declare module bindings', () => { test('a template resolves every binding it declares', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`new Locale(Localization.getLanguageTag(${arg}))`.configure({ - bindings: { - Locale: {module: 'sap/ui/core/Locale', member: 'default'}, - Localization: {module: 'sap/base/i18n/Localization', member: 'default'} - } + context: [ + `import Locale from 'sap/ui/core/Locale';`, + `import Localization from 'sap/base/i18n/Localization';` + ] }), arg); await spec.rewriteRun( @@ -83,7 +82,7 @@ describe('templates that declare module bindings', () => { test('a name in a naming position is not a reference to the binding', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`Theming.setTheme(${arg}.Theming)`.configure({ - bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}} + context: [`import Theming from 'sap/ui/core/Theming';`] }), arg); await spec.rewriteRun( @@ -98,7 +97,7 @@ describe('templates that declare module bindings', () => { test('a binding called on its own is a reference to it, not a name it gives something', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`merge(${arg}, {}).merge()`.configure({ - bindings: {merge: {module: 'sap/base/util/merge', member: 'default'}} + context: [`import merge from 'sap/base/util/merge';`] }), arg); await spec.rewriteRun( @@ -113,7 +112,7 @@ describe('templates that declare module bindings', () => { test('a rule that does not fire leaves the file\'s imports alone', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`Theming.setTheme(${arg})`.configure({ - bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}} + context: [`import Theming from 'sap/ui/core/Theming';`] }), arg); await spec.rewriteRun( @@ -122,22 +121,22 @@ describe('templates that declare module bindings', () => { ); }); - test('a binding is parsed against an import only where the dependencies could resolve it', () => { - expect(bindingContextStatement('Theming', {module: 'sap/ui/core/Theming', member: 'default'}, {})) - .toBe('declare const Theming: any;'); - expect(bindingContextStatement('Theming', {module: 'sap/ui/core/Theming', member: 'default'}, {sap: '^1.0.0'})) - .toBe("import Theming from 'sap/ui/core/Theming';"); + test('a context statement that is not an import binds nothing', async () => { + const arg = capture('arg'); + spec.recipe = recipeApplying(template`helper(${arg})`.configure({ + context: [`declare function helper(x: string): void;`] + }), arg); - expect(bindingContextStatement('Props', {module: '@scope/pkg/props', member: 'Props', typeOnly: true}, {})) - .toBe('type Props = any;'); - expect(bindingContextStatement('Props', {module: '@scope/pkg/props', member: 'Props', typeOnly: true}, {'@scope/pkg': '^1.0.0'})) - .toBe("import type {Props} from '@scope/pkg/props';"); + await spec.rewriteRun( + //language=typescript + typescript(`applyTheme('dark');`, `helper('dark');`) + ); }); - test('a declared binding left unresolved at apply is an error, not a silent wrong name', async () => { + test('a rule given no way to resolve its bindings is an error, not a silent wrong name', async () => { const arg = capture('arg'); const tmpl = template`Theming.setTheme(${arg})`.configure({ - bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}} + context: [`import Theming from 'sap/ui/core/Theming';`] }); const rule = rewrite(() => ({before: pattern`applyTheme(${arg})`, after: tmpl})); @@ -151,6 +150,6 @@ describe('templates that declare module bindings', () => { await expect(spec.rewriteRun( //language=typescript typescript(`applyTheme('dark');`) - )).rejects.toThrow(/Theming/); + )).rejects.toThrow(/needs their local names/); }); });