diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index 07918d4f45f..05d641a9362 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -4,6 +4,7 @@ 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"; export type QuoteChar = "'" | '"'; @@ -32,9 +33,16 @@ export interface AddImportOptions { /** Optional alias for the imported member. * Required when member is 'default' or '*'. + * Taken verbatim, never deconflicted: a caller that names an alias may already have + * emitted code using it. * Cannot be combined with `sideEffectOnly`. */ alias?: string; + /** Preferred local name, deconflicted if the file already binds it. Stands in for `alias` where + * `member` is 'default' or '*' and the caller wants a name it does not insist on. + * Cannot be combined with `sideEffectOnly`. */ + preferredName?: string; + /** If true, only add the import if the member is actually used in the file. Default: true * Cannot be combined with `sideEffectOnly`. */ onlyIfReferenced?: boolean; @@ -60,6 +68,10 @@ 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 @@ -81,22 +93,407 @@ export interface AddImportOptions { * // Add a side-effect import * maybeAddImport(visitor, { module: 'core-js/stable', sideEffectOnly: true }); */ +export function maybeAddImport( + 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 -) { +): string | undefined { + validate(options); + const module = moduleNameOf(options.module); + const sideEffectOnly = options.sideEffectOnly ?? false; + const typeOnly = options.typeOnly ?? false; + + // A queued import binds the module as much as one already in the file, so it answers a later + // request the same way; a name a merged request never emits would be referenced but not bound. + for (const v of visitor.afterVisit || []) { + if (!(v instanceof AddImport) || v.module !== module || + v.sideEffectOnly !== sideEffectOnly || v.typeOnly !== typeOnly) { + continue; + } + // How a specifier prints, or what name would be nice, does not say which binding is wanted. + // A pinned alias does: it asks for a binding of its own, which only the same request answers. + const answers = options.alias + ? v.alias === options.alias && v.member === options.member + : memberName(v.member) === memberName(options.member); + if (answers) { + return v.bindingName; + } + } + + if (sideEffectOnly) { + 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) { + visitor.afterVisit.push(new AddImport(options, options.alias)); + return options.alias; + } + + const derived = derivedName(options); + const cu = compilationUnitOf(visitor); + if (!cu) { + visitor.afterVisit.push(new AddImport(options, derived)); + return derived; + } + + const bindings = bindingsInScope(cu, cursorOf(visitor)); + + // An import already serving this request answers it; queuing one would, on the next cycle, + // derive a suffixed name from the binding this call just added. A caller that named a preference + // takes whatever comes back; one that did not assumes the name it derived, so a binding under + // any other name would leave the references it emits unbound. + for (const binding of bindings) { + if (binding.module === module && binding.member === memberName(options.member) && + binding.typeOnly === typeOnly && + (options.preferredName !== undefined || anyNameAnswers(options) || + binding.name === derived)) { + return binding.name; + } + } + + const name = deconflict(derived, takenNames(bindings, visitor)); + visitor.afterVisit.push(new AddImport(options, name)); + return name; +} + +/** Rejects a request on its own terms, whatever the queue already holds. */ +function validate(options: AddImportOptions): void { + // Validate that a name is provided when member is 'default' + if (options.member === 'default' && !options.alias && !options.preferredName) { + throw new Error("When member is 'default', the alias parameter is required"); + } + + // Validate that a name is provided when member is '*' (namespace import) + if (options.member === '*' && !options.alias && !options.preferredName) { + throw new Error("When member is '*', the alias parameter is required"); + } + + // Validate that sideEffectOnly is not combined with incompatible options + if (options.sideEffectOnly) { + if (options.member !== undefined) { + throw new Error("Cannot combine sideEffectOnly with member"); + } + if (options.alias !== undefined) { + throw new Error("Cannot combine sideEffectOnly with alias"); + } + if (options.preferredName !== undefined) { + throw new Error("Cannot combine sideEffectOnly with preferredName"); + } + if (options.onlyIfReferenced !== undefined) { + throw new Error("Cannot combine sideEffectOnly with onlyIfReferenced"); + } + if (options.typeOnly) { + throw new Error("Cannot combine sideEffectOnly with typeOnly"); + } + } +} + +/** + * A request that named no local name and asks for no member of the module. Having expressed no + * preference it has none to disappoint, so whatever the file already calls that module answers it. + */ +function anyNameAnswers(options: AddImportOptions): boolean { + return !options.sideEffectOnly && memberName(options.member) === undefined && + options.alias === undefined && options.preferredName === undefined; +} + +/** The local name a request asks for, before the file has a say in it. */ +function derivedName(options: AddImportOptions): string { + return options.alias ?? options.preferredName ?? memberName(options.member) ?? moduleNameOf(options.module); +} + +/** `'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 { + 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 { + name: string; + module?: string; + /** Carries the {@link AddImportOptions.member} spelling, so `undefined` means a default import. */ + member?: string; + typeOnly?: boolean; +} + +function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { + return (visitor as unknown as { cursor?: Cursor }).cursor; +} + +function compilationUnitOf(visitor: JavaScriptVisitor): JS.CompilationUnit | undefined { + // `cursor` is protected on `TreeVisitor`, and the `maybeAddImport`/`maybeRemoveImport` + // API is free functions, so reaching it takes a cast. + return cursorOf(visitor)?.firstEnclosing((v): v is JS.CompilationUnit => v?.kind === JS.Kind.CompilationUnit); +} + +/** Every name in scope at `cursor`: what the file binds, and what each block enclosing it declares. */ +function bindingsInScope(cu: JS.CompilationUnit, cursor: Cursor | undefined): ModuleScopeBinding[] { + const bindings = statementBindings(cu.statements); + // A local shadows an import for the code the template lands in, so a name a block declares is + // taken there even though the module never answers for it. + for (let c = cursor; c && c.value !== cu; c = c.parent) { + for (const name of scopeNames(c.value)) { + bindings.push({name}); + } + } + return bindings; +} + +/** The names a scope introduces directly: a block's declarations, or a function's parameters. */ +function scopeNames(scope: unknown): string[] { + const node = scope as J | undefined; + if (node?.kind === J.Kind.Block) { + return statementBindings((node as J.Block).statements).map(binding => binding.name); + } + if (node?.kind === J.Kind.MethodDeclaration) { + return (node as J.MethodDeclaration).parameters.elements + .flatMap(param => param.element?.kind === J.Kind.VariableDeclarations + ? (param.element as J.VariableDeclarations).variables.flatMap(v => patternNames(v.element?.name)) + : patternNames(param.element)) + .map(bound => bound.name); + } + if (node?.kind === J.Kind.Lambda) { + return (node as J.Lambda).parameters.parameters + .flatMap(param => param.element?.kind === J.Kind.VariableDeclarations + ? (param.element as J.VariableDeclarations).variables.flatMap(v => patternNames(v.element?.name)) + : patternNames(param.element)) + .map(bound => bound.name); + } + return []; +} + +/** Imports are top-level statements and so is anything that can shadow one, so a flat scan sees every name. */ +function statementBindings(statements: J.RightPadded[]): ModuleScopeBinding[] { + const bindings: ModuleScopeBinding[] = []; + + const declaredBy = (name: J | undefined): void => { + for (const bound of patternNames(name)) { + bindings.push({name: bound.name}); + } + }; + + const declaredByVariables = (varDecl: J.VariableDeclarations): void => { + for (const variable of varDecl.variables) { + const required = requiredModule(variable.element?.initializer?.element); + if (required !== undefined) { + bindings.push(...requireBindings(variable.element?.name, required)); + } else { + declaredBy(variable.element?.name); + } + } + }; + + for (const stmt of 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; + case J.Kind.MethodDeclaration: + declaredBy((statement as J.MethodDeclaration).name); + break; + case J.Kind.ClassDeclaration: + declaredBy((statement as J.ClassDeclaration).name); + break; + case JS.Kind.NamespaceDeclaration: + declaredBy((statement as JS.NamespaceDeclaration).name.element); + break; + case JS.Kind.TypeDeclaration: + declaredBy((statement as JS.TypeDeclaration).name.element); + break; + } + } + + return bindings; +} + +/** The module a `require('...')` initializer names, `undefined` for an initializer that is anything else. */ +function requiredModule(initializer: J | undefined): string | undefined { + return initializer?.kind === J.Kind.MethodInvocation + ? requiredModuleOf(initializer as J.MethodInvocation) + : undefined; +} + +/** A `require(...)` call, whatever it is passed. `obj.require('x')` selects a method rather than loading a module. */ +function isRequireCall(methodInv: J.MethodInvocation): boolean { + return !methodInv.select && methodInv.name?.kind === J.Kind.Identifier && + (methodInv.name as J.Identifier).simpleName === 'require'; +} + +/** + * 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 { + if (!isRequireCall(methodInv)) { + return undefined; + } + const argument = methodInv.arguments?.elements[0]?.element; + return argument?.kind === J.Kind.Literal && typeof (argument as J.Literal).value === 'string' + ? moduleNameOf(argument as J.Literal) + : undefined; +} + +/** + * Every name a binding pattern introduces. `member` is the property a name takes its value from, + * and only a name bound directly by the pattern has one — anything deeper reads a property of a + * property, so it occupies its name without binding a member of the module. + */ +function patternNames(pattern: J | undefined): { name: string; member?: string }[] { + if (pattern?.kind === J.Kind.Identifier) { + return [{name: (pattern as J.Identifier).simpleName}]; + } + // An array pattern binds by position, so its elements name no member of what they destructure. + if (pattern?.kind === JS.Kind.ArrayBindingPattern) { + return (pattern as JS.ArrayBindingPattern).elements.elements + .flatMap(elem => elem.element?.kind === JS.Kind.BindingElement + ? patternNames((elem.element as JS.BindingElement).name) + : patternNames(elem.element)) + .map(bound => ({name: bound.name})); + } + if (pattern?.kind !== JS.Kind.ObjectBindingPattern) { + return []; + } + const names: { name: string; member?: string }[] = []; + for (const elem of (pattern as JS.ObjectBindingPattern).bindings.elements) { + if (elem.element?.kind !== JS.Kind.BindingElement) { + continue; + } + const bindingElem = elem.element as JS.BindingElement; + if (bindingElem.name?.kind === J.Kind.Identifier) { + const name = (bindingElem.name as J.Identifier).simpleName; + const propertyName = bindingElem.propertyName?.element; + names.push({ + name, + member: propertyName?.kind === J.Kind.Identifier + ? (propertyName as J.Identifier).simpleName + : name + }); + } else { + names.push(...patternNames(bindingElem.name).map(bound => ({name: bound.name}))); + } + } + return names; +} + +/** A `require` binds a module the way an import does, so the pool records it the same way. */ +function requireBindings(pattern: J | undefined, module: string): ModuleScopeBinding[] { + // A whole-module require binds no member, exactly as a default import does. + if (pattern?.kind === J.Kind.Identifier) { + return [{name: (pattern as J.Identifier).simpleName, module, member: undefined, typeOnly: false}]; + } + return patternNames(pattern).map(bound => bound.member === undefined + ? {name: bound.name} + : {name: bound.name, module, member: bound.member, typeOnly: false}); +} + +/** The member a specifier imports and the name it binds it under, which are the same absent an alias. */ +function specifierBinding(specifier: JS.ImportSpecifier): { name: string; member: string } | undefined { + if (specifier.specifier?.kind === J.Kind.Identifier) { + const name = (specifier.specifier as J.Identifier).simpleName; + return {name, member: name}; + } + if (specifier.specifier?.kind !== JS.Kind.Alias) { + return undefined; + } + const alias = specifier.specifier as JS.Alias; + return alias.propertyName?.element?.kind === J.Kind.Identifier && alias.alias?.kind === J.Kind.Identifier + ? {name: (alias.alias as J.Identifier).simpleName, member: (alias.propertyName.element as J.Identifier).simpleName} + : undefined; +} + +function importBindings(jsImport: JS.Import): ModuleScopeBinding[] { + const moduleSpecifier = jsImport.moduleSpecifier?.element; + const module = moduleSpecifier?.kind === J.Kind.Literal + ? moduleNameOf(moduleSpecifier as J.Literal) + : undefined; + const importClause = jsImport.importClause; + if (!importClause) { + return []; + } + + const typeOnly = importClause.typeOnly; + const bindings: ModuleScopeBinding[] = []; + + if (importClause.name?.element?.kind === J.Kind.Identifier) { + bindings.push({name: (importClause.name.element as J.Identifier).simpleName, module, member: undefined, typeOnly}); + } + + const namedBindings = importClause.namedBindings; + if (namedBindings?.kind === J.Kind.Identifier) { + bindings.push({name: (namedBindings as J.Identifier).simpleName, module, member: '*', typeOnly}); + } else if (namedBindings?.kind === JS.Kind.Alias) { + const alias = (namedBindings as JS.Alias).alias; + if (alias?.kind === J.Kind.Identifier) { + bindings.push({name: (alias as J.Identifier).simpleName, module, member: '*', typeOnly}); + } + } else if (namedBindings?.kind === JS.Kind.NamedImports) { + for (const elem of (namedBindings as JS.NamedImports).elements.elements) { + const bound = elem.element?.kind === JS.Kind.ImportSpecifier + ? specifierBinding(elem.element as JS.ImportSpecifier) + : undefined; + if (bound) { + bindings.push({...bound, module, typeOnly}); + } + } + } + + return bindings; +} + +/** + * Names the file binds, plus those pending `AddImport`s on the `afterVisit` queue have claimed. A + * queued `RemoveImport` does not free one: it removes only what the file leaves unused, and binding + * a name it keeps is an error, where an unnecessary suffix merely reads oddly. + */ +function takenNames(bindings: ModuleScopeBinding[], visitor: JavaScriptVisitor): Set { + const taken = new Set(); + for (const v of visitor.afterVisit || []) { - if (v instanceof AddImport && - v.module === moduleNameOf(options.module) && - v.quoteStyle === options.quoteStyle && - v.member === options.member && - v.alias === options.alias && - v.sideEffectOnly === (options.sideEffectOnly ?? false) && - v.typeOnly === (options.typeOnly ?? false)) { - return; + if (v instanceof AddImport && v.bindingName) { + taken.add(v.bindingName); } } - visitor.afterVisit.push(new AddImport(options)); + + for (const binding of bindings) { + taken.add(binding.name); + } + + return taken; +} + +function deconflict(derived: string, taken: Set): string { + if (!taken.has(derived)) { + return derived; + } + let suffix = 1; + while (taken.has(`${derived}_${suffix}`)) { + suffix++; + } + return `${derived}_${suffix}`; } /** @@ -227,35 +624,18 @@ export class AddImport

extends JavaScriptVisitor

{ readonly typeOnly: boolean; readonly style?: ImportStyle; readonly quoteStyle?: QuoteChar; + /** The local name this import binds; `undefined` for a side-effect import. */ + readonly bindingName?: string; + /** + * A default import the request named no local name for, so whatever the file already calls + * that module answers it. + */ + readonly anyNameAnswers: boolean; - constructor(options: AddImportOptions) { + constructor(options: AddImportOptions, bindingName?: string) { super(); - // Validate that alias is provided when member is 'default' - if (options.member === 'default' && !options.alias) { - throw new Error("When member is 'default', the alias parameter is required"); - } - - // Validate that alias is provided when member is '*' (namespace import) - if (options.member === '*' && !options.alias) { - throw new Error("When member is '*', the alias parameter is required"); - } - - // Validate that sideEffectOnly is not combined with incompatible options - if (options.sideEffectOnly) { - if (options.member !== undefined) { - throw new Error("Cannot combine sideEffectOnly with member"); - } - if (options.alias !== undefined) { - throw new Error("Cannot combine sideEffectOnly with alias"); - } - if (options.onlyIfReferenced !== undefined) { - throw new Error("Cannot combine sideEffectOnly with onlyIfReferenced"); - } - if (options.typeOnly) { - throw new Error("Cannot combine sideEffectOnly with typeOnly"); - } - } + validate(options); this.module = moduleNameOf(options.module); this.moduleValueSource = typeof options.module === 'string' ? undefined : options.module.valueSource; @@ -267,6 +647,8 @@ export class AddImport

extends JavaScriptVisitor

{ this.typeOnly = options.typeOnly ?? false; this.style = options.style; this.quoteStyle = options.quoteStyle; + this.bindingName = this.sideEffectOnly ? undefined : bindingName ?? derivedName(options); + this.anyNameAnswers = anyNameAnswers(options); } /** @@ -279,13 +661,6 @@ export class AddImport

extends JavaScriptVisitor

{ return moduleNameOf(moduleSpecifier as J.Literal); } - /** - * Check if a method invocation is a require() call - */ - private isRequireCall(methodInv: J.MethodInvocation): boolean { - return methodInv.name?.kind === J.Kind.Identifier && - (methodInv.name as J.Identifier).simpleName === 'require'; - } /** * Determine the appropriate import style based on file type and existing imports @@ -365,7 +740,7 @@ export class AddImport

extends JavaScriptVisitor

{ const namedVar = varDecl.variables[0].element; const initializer = namedVar?.initializer?.element; if (initializer?.kind === J.Kind.MethodInvocation && - this.isRequireCall(initializer as J.MethodInvocation)) { + isRequireCall(initializer as J.MethodInvocation)) { hasCommonJSRequires = true; } } @@ -442,12 +817,8 @@ export class AddImport

extends JavaScriptVisitor

{ const namedVar = varDecl.variables[0].element; const initializer = namedVar?.initializer?.element; - if (initializer?.kind === J.Kind.MethodInvocation && - this.isRequireCall(initializer as J.MethodInvocation)) { - const moduleName = this.getModuleNameFromRequire(initializer as J.MethodInvocation); - if (moduleName === this.module) { - return ImportStyle.CommonJS; - } + if (requiredModule(initializer) === this.module) { + return ImportStyle.CommonJS; } } } @@ -605,10 +976,10 @@ export class AddImport

extends JavaScriptVisitor

{ const existingElements = namedImports.elements.elements; // Find the correct insertion position (alphabetical, case-insensitive) - const newName = (this.alias || this.member!).toLowerCase(); + const newName = this.bindingName!.toLowerCase(); let insertIndex = existingElements.findIndex(elem => { if (elem.element?.kind === JS.Kind.ImportSpecifier) { - const name = this.getImportAlias(elem.element) || this.getImportName(elem.element); + const name = specifierBinding(elem.element as JS.ImportSpecifier)?.name ?? ''; return newName.localeCompare(name.toLowerCase()) < 0; } return false; @@ -766,91 +1137,27 @@ export class AddImport

extends JavaScriptVisitor

{ * Check if the import matches what we're trying to add */ private isMatchingImport(jsImport: JS.Import): boolean { - // Check module specifier const moduleSpecifier = jsImport.moduleSpecifier?.element; - if (!moduleSpecifier) { - return false; - } - - const moduleName = this.getModuleName(moduleSpecifier); - if (moduleName !== this.module) { + if (!moduleSpecifier || this.getModuleName(moduleSpecifier) !== this.module) { return false; } + // A side-effect import is the clause-less one, and no import carrying bindings stands in + // for it — nor it for them. const importClause = jsImport.importClause; - - // Handle side-effect imports (no import clause) - if (!importClause) { - // If we're trying to add a side-effect import and one already exists, it's a match - return this.sideEffectOnly; + if (!importClause || this.sideEffectOnly) { + return !importClause && this.sideEffectOnly; } - // If we're adding a side-effect import but there's an existing import with bindings, - // it's not a match (side-effect import should be separate) - if (this.sideEffectOnly) { - return false; - } - - // Check if the typeOnly flag matches - type-only and value imports are separate - if (importClause.typeOnly !== this.typeOnly) { - return false; - } - - // Check if the specific member or default import already exists - if (this.member === '*') { - // We're adding a namespace import, check if one exists - const namedBindings = importClause.namedBindings; - if (!namedBindings) { - return false; - } - - // Namespace imports can be represented as J.Identifier or JS.Alias - if (namedBindings.kind === J.Kind.Identifier) { - const identifier = namedBindings as J.Identifier; - return identifier.simpleName === this.alias; - } else if (namedBindings.kind === JS.Kind.Alias) { - const alias = namedBindings as JS.Alias; - if (alias.alias?.kind === J.Kind.Identifier) { - return (alias.alias as J.Identifier).simpleName === this.alias; - } - } - return false; - } else if (this.member === undefined || this.member === 'default') { - // We're adding a default import, check if one exists - // For member === 'default', also verify the alias matches if specified - if (importClause.name === undefined) { - return false; - } - // If we have an alias, check that it matches - if (this.alias && importClause.name.element?.kind === J.Kind.Identifier) { - const existingName = (importClause.name.element as J.Identifier).simpleName; - return existingName === this.alias; - } - return true; - } else { - // We're adding a named import, check if it exists - const namedBindings = importClause.namedBindings; - if (!namedBindings) { - return false; - } - - if (namedBindings.kind === JS.Kind.NamedImports) { - const namedImports = namedBindings as JS.NamedImports; - for (const elem of namedImports.elements.elements) { - if (elem.element?.kind === JS.Kind.ImportSpecifier) { - const specifier = elem.element as JS.ImportSpecifier; - const importName = this.getImportName(specifier); - const aliasName = this.getImportAlias(specifier); - - if (importName === this.member && aliasName === this.alias) { - return true; - } - } - } - } - } + return importBindings(jsImport).some(binding => this.answeredBy(binding)); + } - return false; + /** Whether a binding the file already has serves this request. */ + private answeredBy(binding: ModuleScopeBinding): boolean { + return binding.module === this.module && + binding.member === memberName(this.member) && + binding.typeOnly === this.typeOnly && + (this.anyNameAnswers || binding.name === this.bindingName); } /** @@ -860,52 +1167,12 @@ export class AddImport

extends JavaScriptVisitor

{ if (varDecl.variables.length !== 1) { return false; } - const namedVar = varDecl.variables[0].element; - if (!namedVar) { - return false; - } - - const initializer = namedVar.initializer?.element; - if (!initializer || initializer.kind !== J.Kind.MethodInvocation) { - return false; - } - - const methodInv = initializer as J.MethodInvocation; - if (!this.isRequireCall(methodInv)) { + const module = requiredModule(namedVar?.initializer?.element); + if (module !== this.module) { return false; } - - const moduleName = this.getModuleNameFromRequire(methodInv); - if (moduleName !== this.module) { - return false; - } - - // Check if the variable name matches what we're trying to add - const pattern = namedVar.name; - if ((this.member === undefined || this.member === 'default') && pattern?.kind === J.Kind.Identifier) { - // Default import style: const fs = require('fs') - // For member === 'default', also check the alias matches if specified - if (this.alias) { - const varName = (pattern as J.Identifier).simpleName; - return varName === this.alias; - } - return true; - } else if (this.member !== undefined && this.member !== 'default' && pattern?.kind === JS.Kind.ObjectBindingPattern) { - // Destructured import: const { member } = require('module') - const objectPattern = pattern as JS.ObjectBindingPattern; - for (const elem of objectPattern.bindings.elements) { - if (elem.element?.kind === JS.Kind.BindingElement) { - const bindingElem = elem.element as JS.BindingElement; - const name = (bindingElem.name as J.Identifier)?.simpleName; - if (name === (this.alias || this.member)) { - return true; - } - } - } - } - - return false; + return requireBindings(namedVar!.name, module).some(binding => this.answeredBy(binding)); } /** @@ -1031,7 +1298,7 @@ export class AddImport

extends JavaScriptVisitor

{ } // Step 2: Look for references that match - const targetName = this.alias || this.member; + const targetName = this.bindingName; const targetModule = this.module; let found = false; const self = this; @@ -1206,7 +1473,7 @@ export class AddImport

extends JavaScriptVisitor

{ prefix: singleSpace, markers: emptyMarkers, annotations: [], - simpleName: this.alias!, + simpleName: this.bindingName!, type: undefined, fieldType: undefined }; @@ -1238,7 +1505,7 @@ export class AddImport

extends JavaScriptVisitor

{ prefix: singleSpace, markers: emptyMarkers, annotations: [], - simpleName: this.alias || this.module, + simpleName: this.bindingName!, type: undefined, fieldType: undefined }; @@ -1313,7 +1580,8 @@ export class AddImport

extends JavaScriptVisitor

{ private createImportSpecifier(): JS.ImportSpecifier { let specifier: J.Identifier | JS.Alias; - if (this.alias) { + // An alias equal to the member says nothing `{member}` alone does not. + if (this.bindingName !== this.member) { // Aliased import: import { member as alias } from 'module' const propertyName: J.Identifier = { id: randomId(), @@ -1332,7 +1600,7 @@ export class AddImport

extends JavaScriptVisitor

{ prefix: singleSpace, markers: emptyMarkers, annotations: [], - simpleName: this.alias, + simpleName: this.bindingName!, type: undefined, fieldType: undefined }; @@ -1423,51 +1691,6 @@ export class AddImport

extends JavaScriptVisitor

{ return 0; } - /** - * Get the module name from a require() call - */ - private getModuleNameFromRequire(methodInv: J.MethodInvocation): string | undefined { - const args = methodInv.arguments?.elements; - if (!args || args.length === 0) { - return undefined; - } - const firstArg = args[0].element; - if (!firstArg || firstArg.kind !== J.Kind.Literal || typeof (firstArg as J.Literal).value !== 'string') { - return undefined; - } - return moduleNameOf(firstArg as J.Literal); - } - - /** - * Get the import name from an import specifier - */ - private getImportName(specifier: JS.ImportSpecifier): string { - const spec = specifier.specifier; - if (spec?.kind === JS.Kind.Alias) { - const alias = spec as JS.Alias; - const propertyName = alias.propertyName.element; - if (propertyName?.kind === J.Kind.Identifier) { - return (propertyName as J.Identifier).simpleName; - } - } else if (spec?.kind === J.Kind.Identifier) { - return (spec as J.Identifier).simpleName; - } - return ''; - } - - /** - * Get the import alias from an import specifier - */ - private getImportAlias(specifier: JS.ImportSpecifier): string | undefined { - const spec = specifier.specifier; - if (spec?.kind === JS.Kind.Alias) { - const alias = spec as JS.Alias; - if (alias.alias?.kind === J.Kind.Identifier) { - return (alias.alias as J.Identifier).simpleName; - } - } - return undefined; - } } diff --git a/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts b/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts index d7f87c733cc..22b255c4b7d 100644 --- a/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts @@ -164,17 +164,14 @@ export class ChangeImport extends Recipe { alias: aliasToUse, onlyIfReferenced: false }); - } else if (aliasToUse && aliasToUse !== newMember) { - maybeAddImport(this, { - module: newModule, - member: newMember, - alias: aliasToUse, - onlyIfReferenced: false - }); } else { maybeAddImport(this, { module: newModule, member: newMember, + // A moved binding keeps the local name it had. Pinning it also tells + // `maybeAddImport` not to deconflict against the import being replaced, + // which is still present in the tree it reads. + alias: aliasToUse ?? newMember, onlyIfReferenced: false }); } diff --git a/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts b/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts new file mode 100644 index 00000000000..56f396bd803 --- /dev/null +++ b/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts @@ -0,0 +1,129 @@ +/* + * 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, 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 + * actually binds. Runs before parameter substitution, so only the template's own code is in + * scope and a caller's captured code is never rewritten. + */ +export async function renameBindings(tree: T, renames: Record, modules: Record): Promise { + return new RenameBindingsVisitor(renames, modules).visit(tree, undefined) as Promise; +} + +class RenameBindingsVisitor extends JavaScriptVisitor { + constructor(private readonly renames: Record, + private readonly modules: Record) { + super(); + } + + override async visitIdentifier(identifier: J.Identifier, p: undefined): Promise { + const renamed = this.renames[identifier.simpleName]; + if (renamed === undefined || renamed === identifier.simpleName) { + return identifier; + } + + // Attribution settles it where the context import resolved. It is absent for a module the + // parse could not reach, and the identifier's position decides instead. + const resolved = resolvedModule(identifier); + const refersToBinding = resolved !== undefined + ? resolved === this.modules[identifier.simpleName] + : !namesItsParent(this.cursor, identifier); + + return refersToBinding ? {...identifier, simpleName: renamed} as J.Identifier : identifier; + } +} + +/** Whether the parent is naming this identifier — as a property, a method, a declaration — rather than referencing it. */ +function namesItsParent(cursor: Cursor, identifier: J.Identifier): boolean { + let c: Cursor | undefined = cursor.parent; + while (c && isPadding(c.value)) { + c = c.parent; + } + const parent = c?.value as { kind?: string; name?: unknown; select?: unknown } | undefined; + + // A call names a member of whatever it selects from. With nothing selected there is no member, + // and its `name` is a reference to the function being called. + if (parent?.kind === J.Kind.MethodInvocation && !parent.select) { + return false; + } + + const name = parent?.name; + return name === identifier || (name as { element?: unknown } | undefined)?.element === identifier; +} + +function isPadding(value: unknown): boolean { + const kind = (value as { kind?: string } | undefined)?.kind; + return kind === J.Kind.RightPadded || kind === J.Kind.LeftPadded || kind === J.Kind.Container; +} + +/** The module an identifier's attribution traces back to, following the owning-class chain to its root. */ +function resolvedModule(identifier: J.Identifier): string | undefined { + const fieldType = identifier.fieldType; + if (fieldType?.kind === Type.Kind.Variable) { + const owner = (fieldType as Type.Variable).owner; + return owner && Type.isClass(owner) ? rootName(owner as Type.Class) : undefined; + } + const type = identifier.type; + if (type && Type.isMethod(type)) { + const declaring = (type as Type.Method).declaringType; + return declaring ? rootName(declaring as Type.Class) : undefined; + } + if (type && Type.isClass(type)) { + return rootName(type as Type.Class); + } + return undefined; +} + +function rootName(classType: Type.Class): string { + let current: Type.Class = classType; + while (current.owningClass && Type.isClass(current.owningClass)) { + current = current.owningClass as Type.Class; + } + return Type.FullyQualified.getFullyQualifiedName(current); +} diff --git a/rewrite-javascript/rewrite/src/javascript/templating/engine.ts b/rewrite-javascript/rewrite/src/javascript/templating/engine.ts index c58e1ae07c3..aa7c50313b8 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/engine.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/engine.ts @@ -23,6 +23,7 @@ import {PlaceholderReplacementVisitor} from './placeholder-replacement'; import {maybeParenthesize, parenthesize, requiredPrecedence, startsWithDeclarationToken} from './precedence'; import {JavaCoordinates} from './template'; import {maybeAutoFormat} from '../format'; +import {renameBindings} from './bindings'; import {isExpression, isStatement} from '../parser-utils'; import {randomId} from '../../uuid'; import ts from "typescript"; @@ -257,6 +258,8 @@ export class TemplateEngine { * @param values Map of capture names to values to replace the parameters with * @param wrappersMap Map of capture names to J.RightPadded wrappers (for preserving markers) * @param format Whether to fit the result to where it lands + * @param renames Local names for the template's declared bindings, keyed as declared + * @param modules The module each declared binding names, keyed as declared * @returns A Promise resolving to the generated AST node */ static async applyTemplateFromAst( @@ -266,7 +269,9 @@ export class TemplateEngine { coordinates: JavaCoordinates, values: Pick, 'get'> = new Map(), wrappersMap: Pick | J.RightPadded[]>, 'get'> = new Map(), - format: boolean = true + format: boolean = true, + renames: Record = {}, + modules: Record = {} ): Promise { // Create substitutions map for placeholders const substitutions = new Map(); @@ -278,9 +283,13 @@ export class TemplateEngine { // Before substitution, so that ids carried over from the source tree survive this pass const fresh = await randomizeIds(ast); + const bound = Object.keys(renames).length > 0 + ? await renameBindings(fresh.tree as J, renames, modules) + : fresh.tree; + // Unsubstitute placeholders with actual parameter values and match results const visitor = new PlaceholderReplacementVisitor(substitutions, values, wrappersMap); - const unsubstitutedAst = (await visitor.visit(fresh.tree, null))!; + const unsubstitutedAst = (await visitor.visit(bound, null))!; // An id may only be kept where the node answering to it is leaving the tree, which is the // subtree this application replaces. A parameter named twice, or spliced in from somewhere diff --git a/rewrite-javascript/rewrite/src/javascript/templating/index.ts b/rewrite-javascript/rewrite/src/javascript/templating/index.ts index e038fc2b5ad..376c751d2ab 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/index.ts @@ -25,7 +25,9 @@ export type { MatchOptions, TemplateParameter, TemplateOptions, + ModuleBinding, RewriteRule, + TryOnOptions, RewriteConfig, DebugOptions, DebugLogEntry, diff --git a/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts b/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts index 533cca9943c..56ed9f6fe8f 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts @@ -15,7 +15,7 @@ */ import {Cursor, ExecutionContext, Recipe, TreeVisitor} from '../..'; import {J, Statement} from '../../java'; -import {PostMatchContext, PreMatchContext, RewriteConfig, RewriteRule} from './types'; +import {PostMatchContext, PreMatchContext, RewriteConfig, RewriteRule, TryOnOptions} from './types'; import {MatchResult, Pattern} from './pattern'; import {Template} from './template'; import {JavaScriptVisitor} from '../visitor'; @@ -33,7 +33,7 @@ class RewriteRuleImpl implements RewriteRule { ) { } - async tryOn(cursor: Cursor, node: J): Promise { + async tryOn(cursor: Cursor, node: J, options?: TryOnOptions): Promise { // Evaluate preMatch before attempting any pattern matching if (this.preMatch) { const preMatchResult = await this.preMatch(node, { cursor }); @@ -57,12 +57,10 @@ class RewriteRuleImpl implements RewriteRule { // Apply transformation let result: J | undefined; - const options = { values: match, format: this.format }; - if (typeof this.after === 'function') { - result = await this.after(match).apply(node, cursor, options); - } else { - result = await this.after.apply(node, cursor, options); - } + const template = typeof this.after === 'function' ? this.after(match) : this.after; + const bindings = options?.bindings ?? (options?.visitor && template.resolveBindings(options.visitor)); + result = await template.apply(node, cursor, + { values: match, format: this.format, bindings: bindings || undefined }); if (result) { return result; @@ -83,10 +81,10 @@ class RewriteRuleImpl implements RewriteRule { super([], () => undefined as unknown as Template); } - async tryOn(cursor: Cursor, node: J): Promise { - const firstResult = await first.tryOn(cursor, node); + async tryOn(cursor: Cursor, node: J, options?: TryOnOptions): Promise { + const firstResult = await first.tryOn(cursor, node, options); if (firstResult !== undefined) { - const secondResult = await next.tryOn(cursor, firstResult); + const secondResult = await next.tryOn(cursor, firstResult, options); return secondResult ?? firstResult; } return undefined; @@ -103,12 +101,12 @@ class RewriteRuleImpl implements RewriteRule { super([], () => undefined as unknown as Template); } - async tryOn(cursor: Cursor, node: J): Promise { - const firstResult = await first.tryOn(cursor, node); + async tryOn(cursor: Cursor, node: J, options?: TryOnOptions): Promise { + const firstResult = await first.tryOn(cursor, node, options); if (firstResult !== undefined) { return firstResult; } - return await alternative.tryOn(cursor, node); + return await alternative.tryOn(cursor, node, options); } })(); } diff --git a/rewrite-javascript/rewrite/src/javascript/templating/template.ts b/rewrite-javascript/rewrite/src/javascript/templating/template.ts index eba44112fe9..fdc0a9c69e9 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/template.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/template.ts @@ -16,6 +16,9 @@ import {Cursor, Tree} from '../..'; import {J} from '../../java'; import {ApplyOptions, Parameter, TemplateOptions, TemplateParameter} from './types'; +import {bindingContextStatement, isResolvable} from './bindings'; +import {maybeAddImport} from '../add-import'; +import {JavaScriptVisitor} from '../visitor'; import {MatchResult} from './pattern'; import {generateCacheKey, globalAstCache, WRAPPERS_MAP_SYMBOL} from './utils'; import {CAPTURE_NAME_SYMBOL, RAW_CODE_SYMBOL} from './capture'; @@ -246,7 +249,11 @@ export class Template { // Generate cache key for global lookup // For raw() parameters, we need to include their code values in the key // since they're spliced at construction time, not application time - const contextStatements = this.options.context || this.options.imports || []; + const contextStatements = [ + ...(this.options.context || this.options.imports || []), + ...Object.entries(this.options.bindings ?? {}) + .map(([name, b]) => bindingContextStatement(name, b, this.options.dependencies ?? {})) + ]; const parametersKey = this.parameters.map((p, i) => { const value = p.value; // Include raw code values in the cache key using the symbol @@ -285,6 +292,24 @@ export class Template { return result; } + /** + * Binds every module this template declares in the file `visitor` is traversing, and returns + * the local names to hand back through {@link ApplyOptions.bindings}. A module the dependencies + * cannot resolve is bound whether or not the template goes on to reference it, so call this + * where the template is known to apply — {@link RewriteRule.tryOn} does, once a pattern matched. + */ + resolveBindings(visitor: JavaScriptVisitor): Record { + 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}); + } + return resolved; + } + /** * Applies this template and returns the resulting tree. * @@ -347,6 +372,19 @@ 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().`); + } + renames[name] = bound; + modules[name] = binding.module; + } + // Use instance-level cache to get the template tree const ast = await this.getTemplateTree(); @@ -361,7 +399,9 @@ export class Template { }, normalizedValues, wrappersMap, - options?.format ?? true + options?.format ?? true, + renames, + modules ); } } diff --git a/rewrite-javascript/rewrite/src/javascript/templating/types.ts b/rewrite-javascript/rewrite/src/javascript/templating/types.ts index 04ccdc4a834..b0e3729c1b6 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/types.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/types.ts @@ -18,6 +18,8 @@ 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"; /** * Options for variadic captures that match zero or more nodes in a sequence. @@ -481,8 +483,29 @@ export interface TemplateOptions { * The template engine will create a package.json with these dependencies. */ 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. + * + * @example + * ```typescript + * template`Theming.setTheme(${capture('theme')})` + * .configure({bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}}}) + * ``` + */ + bindings?: Record; } +/** + * 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. */ @@ -516,6 +539,25 @@ export interface ApplyOptions { * The anchor supplies the result's prefix either way. */ format?: boolean; + + /** + * Local names for the bindings the template declares, keyed as they are declared — normally + * {@link Template.resolveBindings}. Every declared binding needs one. + */ + bindings?: Record; +} + +/** Options a caller supplies per node, as against the ones {@link RewriteConfig} fixes for the rule. */ +export interface TryOnOptions { + /** + * The visitor to bind the applied template's declared modules in. The rule holds that template, + * so it resolves them itself, and only once a pattern has matched — a rule that does not fire + * leaves the file's imports alone. {@link TryOnOptions.bindings} overrides this. + */ + visitor?: JavaScriptVisitor; + + /** As {@link ApplyOptions.bindings}, for a caller that resolves the modules some other way. */ + bindings?: Record; } /** @@ -531,7 +573,7 @@ export interface RewriteRule { * When using in a visitor, always use the `|| node` pattern to return the original * node when there's no match: `return await rule.tryOn(this.cursor, node) || node;` */ - tryOn(cursor: Cursor, node: J): Promise; + tryOn(cursor: Cursor, node: J, options?: TryOnOptions): Promise; /** * Chains this rule with another rule, creating a composite rule that applies both transformations sequentially. diff --git a/rewrite-javascript/rewrite/test/javascript/add-import.test.ts b/rewrite-javascript/rewrite/test/javascript/add-import.test.ts index 21b609c423a..94b74485781 100644 --- a/rewrite-javascript/rewrite/test/javascript/add-import.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/add-import.test.ts @@ -2625,4 +2625,482 @@ describe('AddImport visitor', () => { }); }); + + describe('bound name', () => { + /** + * Calls from `visitJsCompilationUnit` rather than the constructor, so that the cursor + * reaches the compilation unit and the name can be resolved against it. + */ + function captureBoundName(options: AddImportOptions, bound: { name?: string }): JavaScriptVisitor { + return new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.name = maybeAddImport(this, options); + return super.visitJsCompilationUnit(cu, p); + } + }; + } + + test('an import that already binds the member is the answer, not a conflict', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName( + {module: 'fs', member: 'readFile', onlyIfReferenced: false}, bound)); + + await spec.rewriteRun( + typescript( + ` + import {readFile} from 'fs'; + + readFile('x'); + ` + ) + ); + + expect(bound.name).toBe('readFile'); + }); + + test('a module-scope declaration of the same name pushes the binding aside', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName( + {module: 'fs', member: 'readFile', onlyIfReferenced: false}, bound)); + + await spec.rewriteRun( + typescript( + ` + const readFile = 1; + `, + ` + import {readFile as readFile_1} from 'fs'; + + const readFile = 1; + ` + ) + ); + + expect(bound.name).toBe('readFile_1'); + }); + + test('an import binding of the same name from another module pushes the binding aside', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName( + {module: 'fs', member: 'readFile', onlyIfReferenced: false}, bound)); + + await spec.rewriteRun( + typescript( + ` + import {readFile} from 'node:fs'; + + readFile('x'); + `, + ` + import {readFile} from 'node:fs'; + import {readFile as readFile_1} from 'fs'; + + readFile('x'); + ` + ) + ); + + expect(bound.name).toBe('readFile_1'); + }); + + test('an explicitly pinned alias is honoured even into a collision', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName( + {module: 'fs', member: 'readFile', alias: 'readFile', onlyIfReferenced: false}, bound)); + + await spec.rewriteRun( + typescript( + ` + const readFile = 1; + `, + ` + import {readFile} from 'fs'; + + const readFile = 1; + ` + ) + ); + + expect(bound.name).toBe('readFile'); + }); + + test('two calls in one visit resolve against each other', async () => { + const spec = new RecipeSpec(); + const first: { name?: string } = {}; + const second: { name?: string } = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + first.name = maybeAddImport(this, {module: 'fs', member: 'readFile', onlyIfReferenced: false}); + second.name = maybeAddImport(this, {module: 'fs/promises', member: 'readFile', onlyIfReferenced: false}); + return super.visitJsCompilationUnit(cu, p); + } + }); + + await spec.rewriteRun( + typescript( + ` + const x = 1; + `, + ` + import {readFile} from 'fs'; + import {readFile as readFile_1} from 'fs/promises'; + + const x = 1; + ` + ) + ); + + expect(first.name).toBe('readFile'); + expect(second.name).toBe('readFile_1'); + }); + + test('onlyIfReferenced looks for the deconflicted name, so a shadowed member is not imported', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName({module: 'fs', member: 'readFile'}, bound)); + + await spec.rewriteRun( + typescript( + ` + const readFile = 1; + + readFile; + ` + ) + ); + + expect(bound.name).toBe('readFile_1'); + }); + + test('a preferred name is deconflicted, unlike a pinned alias', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName( + {module: 'sap/ui/core/Theming', member: 'default', preferredName: 'Theming', onlyIfReferenced: false}, bound)); + + await spec.rewriteRun( + typescript( + ` + const Theming = 1; + `, + ` + import Theming_1 from 'sap/ui/core/Theming'; + + const Theming = 1; + ` + ) + ); + + expect(bound.name).toBe('Theming_1'); + }); + + test("a default import is reused whether the request spells the member 'default' or omits it", async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName( + {module: 'sap/ui/core/Theming', member: 'default', preferredName: 'Theming', onlyIfReferenced: false}, bound)); + + await spec.rewriteRun( + typescript( + ` + import Th from 'sap/ui/core/Theming'; + + Th.setTheme('dark'); + ` + ) + ); + + expect(bound.name).toBe('Th'); + }); + + test('a pinned alias that names the member matches the plain specifier already there', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName( + {module: 'fs', member: 'readFile', alias: 'readFile', onlyIfReferenced: false}, bound)); + + await spec.rewriteRun( + typescript( + ` + import {readFile} from 'fs'; + + readFile('x'); + ` + ) + ); + + expect(bound.name).toBe('readFile'); + }); + + test('a require of the same member answers the request', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName( + {module: 'fs', member: 'readFile', onlyIfReferenced: false}, bound)); + + await spec.rewriteRun( + typescript( + ` + const {readFile} = require('fs'); + + readFile('x'); + ` + ) + ); + + expect(bound.name).toBe('readFile'); + }); + + test('a binding renamed where it is declared does not answer a request that named no preference', async () => { + const spec = new RecipeSpec(); + 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})); + return super.visitJsCompilationUnit(cu, p); + } + }); + + await spec.rewriteRun( + typescript( + ` + import {useState as useS} from 'react'; + const {writeFile: wf} = require('fs/promises'); + + useS(0); + wf('x'); + `, + ` + import {useState as useS, useState} from 'react'; + import {writeFile} from 'fs/promises'; + const {writeFile: wf} = require('fs/promises'); + + useS(0); + wf('x'); + ` + ) + ); + + expect(bound).toEqual(['useState', 'writeFile']); + }); + + test('a namespace or type declaration shadows an import just as a value declaration does', async () => { + const spec = new RecipeSpec(); + 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})); + return super.visitJsCompilationUnit(cu, p); + } + }); + + await spec.rewriteRun( + typescript( + ` + namespace join { export const a = 1; } + type shape = string; + `, + ` + import {join as join_1} from 'm'; + import {shape as shape_1} from 'n'; + + namespace join { export const a = 1; } + type shape = string; + ` + ) + ); + + expect(bound).toEqual(['join_1', 'shape_1']); + }); + + test('a second request for the same binding gets the name the first settled on', async () => { + const spec = new RecipeSpec(); + const quoted: string[] = []; + 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})); + + 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); + } + }); + + await spec.rewriteRun( + typescript( + ` + const x = 1; + `, + ` + import {readFile} from 'fs'; + import Theming from 'theme'; + + const x = 1; + ` + ) + ); + + expect(quoted).toEqual(['readFile', 'readFile']); + expect(preferred).toEqual(['Theming', 'Theming']); + }); + + test('a name bound through any binding pattern is occupied without answering for the module', async () => { + const spec = new RecipeSpec(); + 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: 'b', onlyIfReferenced: false})); + bound.push(maybeAddImport(this, {module: 'other', preferredName: 'z', onlyIfReferenced: false})); + bound.push(maybeAddImport(this, {module: 'p', member: 'default', preferredName: 'e', onlyIfReferenced: false})); + return super.visitJsCompilationUnit(cu, p); + } + }); + + await spec.rewriteRun( + typescript( + ` + const {a: {b}} = require('other'); + const [e] = window; + + b(); + `, + ` + import {b as b_1} from 'm'; + import z from 'other'; + import e_1 from 'p'; + + const {a: {b}} = require('other'); + const [e] = window; + + b(); + ` + ) + ); + + expect(bound).toEqual(['b_1', 'z', 'e_1']); + }); + + test('a merged specifier sorts by the name it binds, as its neighbours do', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName( + {module: 'm', member: 'zzz', preferredName: 'aaa', onlyIfReferenced: false}, bound)); + + await spec.rewriteRun( + typescript( + ` + import {mmm} from 'm'; + + mmm(); + `, + ` + import {zzz as aaa, mmm} from 'm'; + + mmm(); + ` + ) + ); + + expect(bound.name).toBe('aaa'); + }); + + test('a name an enclosing scope declares is taken where the import would be used', async () => { + const spec = new RecipeSpec(); + const bound: string[] = []; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + // Each anchor asks for a different module, so the queue never answers one from + // another and every scope is resolved on its own. + 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})); + } + return super.visitMethodInvocation(method, p); + } + }); + + await spec.rewriteRun( + typescript( + ` + function byParameter(merge) { + anchor(); + } + + function byDeclaration() { + const merge = 1; + anchor(); + } + + const byLambda = (merge) => anchor(); + `, + ` + import {merge as merge_1} from 'm0'; + import {merge as merge_2} from 'm1'; + import {merge as merge_3} from 'm2'; + + function byParameter(merge) { + anchor(); + } + + function byDeclaration() { + const merge = 1; + anchor(); + } + + const byLambda = (merge) => anchor(); + ` + ) + ); + + expect(bound).toEqual(['merge_1', 'merge_2', 'merge_3']); + }); + + test('a request that named nothing takes the name the file already gives the module', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {}; + spec.recipe = fromVisitor(captureBoundName({module: 'lodash', onlyIfReferenced: false}, bound)); + + await spec.rewriteRun( + typescript( + ` + import Foo from 'lodash'; + + Foo(); + ` + ) + ); + + expect(bound.name).toBe('Foo'); + }); + + test('a side-effect import binds no name', async () => { + const spec = new RecipeSpec(); + const bound: { name?: string } = {name: 'unset'}; + spec.recipe = fromVisitor(captureBoundName({module: 'core-js/stable', sideEffectOnly: true}, bound)); + + await spec.rewriteRun( + typescript( + ` + const x = 1; + `, + ` + import 'core-js/stable'; + + const x = 1; + ` + ) + ); + + expect(bound.name).toBeUndefined(); + }); + }); + }); diff --git a/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts b/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts new file mode 100644 index 00000000000..6b3e0fcd993 --- /dev/null +++ b/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts @@ -0,0 +1,156 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {fromVisitor, RecipeSpec} from "../../../src/test"; +import {capture, JavaScriptVisitor, pattern, 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(); + + /** Rewrites every `applyTheme(...)` through `tmpl`, resolving the template's bindings per file. */ + function recipeApplying(tmpl: ReturnType, arg: ReturnType) { + const rule = rewrite(() => ({before: pattern`applyTheme(${arg})`, after: tmpl})); + return fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise { + method = await super.visitMethodInvocation(method, p) as J.MethodInvocation; + return await rule.tryOn(this.cursor, method, {visitor: this}) || method; + } + }); + } + + 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'}} + }), arg); + + await spec.rewriteRun( + //language=typescript + typescript( + `const Theming = 1;\napplyTheme('dark');`, + `import Theming_1 from 'sap/ui/core/Theming';\n\nconst Theming = 1;\nTheming_1.setTheme('dark');` + ) + ); + }); + + 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'}} + }), arg); + + await spec.rewriteRun( + //language=typescript + typescript( + `import Th from 'sap/ui/core/Theming';\n\napplyTheme('dark');`, + `import Th from 'sap/ui/core/Theming';\n\nTh.setTheme('dark');` + ) + ); + }); + + 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'} + } + }), arg); + + await spec.rewriteRun( + //language=typescript + typescript( + `applyTheme('dark');`, + `import Locale from 'sap/ui/core/Locale';\nimport Localization from 'sap/base/i18n/Localization';\n\nnew Locale(Localization.getLanguageTag('dark'));` + ) + ); + }); + + 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'}} + }), arg); + + await spec.rewriteRun( + //language=typescript + typescript( + `const Theming = 1;\napplyTheme(props);`, + `import Theming_1 from 'sap/ui/core/Theming';\n\nconst Theming = 1;\nTheming_1.setTheme(props.Theming);` + ) + ); + }); + + 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'}} + }), arg); + + await spec.rewriteRun( + //language=typescript + typescript( + `const merge = 1;\napplyTheme('dark');`, + `import merge_1 from 'sap/base/util/merge';\n\nconst merge = 1;\nmerge_1('dark', {}).merge();` + ) + ); + }); + + 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'}} + }), arg); + + await spec.rewriteRun( + //language=typescript + typescript(`const x = Theming;\nsomethingElse('dark');`) + ); + }); + + 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';"); + + 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';"); + }); + + test('a declared binding left unresolved at apply 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'}} + }); + const rule = rewrite(() => ({before: pattern`applyTheme(${arg})`, after: tmpl})); + + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise { + method = await super.visitMethodInvocation(method, p) as J.MethodInvocation; + return await rule.tryOn(this.cursor, method) || method; + } + }); + + await expect(spec.rewriteRun( + //language=typescript + typescript(`applyTheme('dark');`) + )).rejects.toThrow(/Theming/); + }); +});