@travetto/transformer
Version:
Functionality for AST transformations, with transformer registration, and general utils
259 lines (230 loc) • 9.04 kB
text/typescript
import ts from 'typescript';
import { ManifestModuleUtil, PackageUtil, path } from '@travetto/manifest';
import type { AnyType, TransformResolver, ManagedType, MappedType } from './resolver/types.ts';
import { ImportUtil } from './util/import.ts';
import { CoreUtil } from './util/core.ts';
import type { Import } from './types/shared.ts';
import { LiteralUtil } from './util/literal.ts';
import { DeclarationUtil } from './util/declaration.ts';
const D_OR_D_TS_EXT_REGEX = /[.]d([.]ts)?$/;
/**
* Manages imports within a ts.SourceFile
*/
export class ImportManager {
#newImports = new Map<string, Import>();
#imports: Map<string, Import>;
#idx: Record<string, number> = {};
#identifiers = new Map<string, ts.Identifier>();
#importName: string;
#resolver: TransformResolver;
source: ts.SourceFile;
factory: ts.NodeFactory;
constructor(source: ts.SourceFile, factory: ts.NodeFactory, resolver: TransformResolver) {
this.#imports = ImportUtil.collectImports(source);
this.#resolver = resolver;
this.#importName = this.#resolver.getFileImportName(source.fileName);
this.source = source;
this.factory = factory;
}
#rewriteImportClause(expr: ts.Expression | undefined, clause: ts.ImportClause | undefined): ts.ImportClause | undefined {
if (!(expr && clause?.namedBindings && ts.isNamedImports(clause.namedBindings))) {
return clause;
}
if (expr && ts.isStringLiteral(expr) && !this.#isKnownImport(expr)) {
return clause;
}
const bindings = clause.namedBindings;
const newBindings: ts.ImportSpecifier[] = [];
// Remove all type only imports
for (const element of bindings.elements) {
if (!element.isTypeOnly) {
const type = this.#resolver.getType(element.name);
const objFlags = DeclarationUtil.getObjectFlags(type);
const typeFlags = type.getFlags();
// eslint-disable-next-line no-bitwise
if (!(objFlags & (ts.SymbolFlags.Type | ts.SymbolFlags.Interface)) || !(typeFlags & ts.TypeFlags.Any)) {
newBindings.push(element);
}
}
}
if (newBindings.length !== bindings.elements.length) {
return this.factory.updateImportClause(
clause,
clause.isTypeOnly,
clause.name,
this.factory.createNamedImports(newBindings)
);
} else {
return clause;
}
}
/**
* Is a known import an untyped file access
* @param fileOrImport
*/
#isKnownImport(fileOrImport: ts.StringLiteral | string | undefined): boolean {
if (fileOrImport && typeof fileOrImport !== 'string') {
if (ts.isStringLiteral(fileOrImport)) {
fileOrImport = fileOrImport.text.replace(/['"]g/, '');
} else {
return false;
}
}
return fileOrImport ?
(fileOrImport.startsWith('.') || this.#resolver.isKnownFile(fileOrImport)) :
false;
}
/**
* Normalize module specifier
*/
normalizeModuleSpecifier<T extends ts.Expression | undefined>(specifier: T): T {
if (specifier && ts.isStringLiteral(specifier) && this.#isKnownImport(specifier.text)) {
const specText = specifier.text.replace(/['"]/g, '');
const type = ManifestModuleUtil.getFileType(specText);
if (type === 'js' || type === 'ts') {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return LiteralUtil.fromLiteral(this.factory, ManifestModuleUtil.withOutputExtension(specText)) as unknown as T;
} else {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return LiteralUtil.fromLiteral(this.factory, `${specText}${ManifestModuleUtil.OUTPUT_EXT}`) as unknown as T;
}
}
return specifier;
}
/**
* Produces a unique identifier for a given file
*/
getIdentifier(file: string, name?: string): ts.Identifier {
if (!this.#identifiers.has(file)) {
if (name) {
this.#identifiers.set(file, this.factory.createIdentifier(name));
} else {
const key = path.basename(file, path.extname(file)).replace(/\W+/g, '_');
const suffix = this.#idx[key] = (this.#idx[key] ?? -1) + 1;
this.#identifiers.set(file, this.factory.createIdentifier(`Δ${key}${suffix ? suffix : ''}`));
}
}
return this.#identifiers.get(file)!;
}
/**
* Import a file if needed, and record it's identifier
*/
importFile(file: string, name?: string): Import {
file = this.#resolver.getFileImportName(file);
if (file.endsWith(ManifestModuleUtil.SOURCE_DEF_EXT) && !file.endsWith(ManifestModuleUtil.TYPINGS_EXT)) {
file = ManifestModuleUtil.withOutputExtension(file);
}
// Allow for node classes to be imported directly
if (/@types\/node\//.test(file)) {
file = PackageUtil.resolveImport(file.split('@types/node/')[1].replace(D_OR_D_TS_EXT_REGEX, ''));
}
if (!D_OR_D_TS_EXT_REGEX.test(file) && !this.#newImports.has(file)) {
const identifier = this.getIdentifier(file, name);
const uniqueName = identifier.text;
if (this.#imports.has(uniqueName)) { // Already imported, be cool
return this.#imports.get(uniqueName)!;
}
const newImport = { path: file, identifier };
this.#imports.set(uniqueName, newImport);
this.#newImports.set(file, newImport);
}
return this.#newImports.get(file)!;
}
/**
* Import given an external type
*/
importFromResolved(...types: AnyType[]): void {
for (const type of types) {
if (type.key === 'managed' && type.importName && type.importName !== this.#importName) {
this.importFile(type.importName);
}
switch (type.key) {
case 'managed':
case 'literal': this.importFromResolved(...type.typeArguments || []); break;
case 'composition':
case 'tuple': this.importFromResolved(...type.subTypes || []); break;
case 'shape': this.importFromResolved(...Object.values(type.fieldTypes)); break;
}
}
}
/**
* Add imports to a source file
*/
finalizeNewImports(file: ts.SourceFile): ts.SourceFile | undefined {
if (!this.#newImports.size) {
return;
}
try {
const importStmts = [...this.#newImports.values()].map(({ path: resolved, identifier }) => {
const importStmt = this.factory.createImportDeclaration(
undefined,
this.factory.createImportClause(undefined, undefined, this.factory.createNamespaceImport(identifier)),
this.factory.createStringLiteral(resolved)
);
return importStmt;
});
return CoreUtil.updateSource(this.factory, file, [
...importStmts,
...file.statements.filter((node: ts.Statement & { remove?: boolean }) => !node.remove) // Exclude culled imports
]);
} catch (error) { // Missing import
if (!(error instanceof Error)) {
throw error;
}
const out = new Error(`${error.message} in ${file.fileName.replace(process.cwd(), '.')}`);
out.stack = error.stack;
throw out;
}
}
finalizeImportExportExtension(source: ts.SourceFile): ts.SourceFile {
const toAdd: ts.Statement[] = [];
for (const statement of source.statements) {
if (ts.isExportDeclaration(statement)) {
if (!statement.isTypeOnly) {
toAdd.push(this.factory.updateExportDeclaration(
statement,
statement.modifiers,
statement.isTypeOnly,
statement.exportClause,
this.normalizeModuleSpecifier(statement.moduleSpecifier),
statement.attributes
));
}
} else if (ts.isImportDeclaration(statement)) {
if (statement.importClause?.phaseModifier !== ts.SyntaxKind.TypeKeyword) {
toAdd.push(this.factory.updateImportDeclaration(
statement,
statement.modifiers,
this.#rewriteImportClause(statement.moduleSpecifier, statement.importClause)!,
this.normalizeModuleSpecifier(statement.moduleSpecifier)!,
statement.attributes
));
}
} else {
toAdd.push(statement);
}
}
return CoreUtil.updateSource(this.factory, source, toAdd);
}
/**
* Reset the imports into the source file
*/
finalize(source: ts.SourceFile): ts.SourceFile {
let node = this.finalizeNewImports(source) ?? source;
node = this.finalizeImportExportExtension(node) ?? node;
return node;
}
/**
* Get the identifier and import if needed
*/
getOrImport(factory: ts.NodeFactory, type: ManagedType | MappedType): ts.Identifier | ts.PropertyAccessExpression {
const targetName = type.key === 'managed' ? type.name! : type.mappedClassName!;
// In same file already
if (type.importName === this.#importName) {
return factory.createIdentifier(targetName);
} else {
const { identifier } = this.#imports.get(type.importName) ?? this.importFile(type.importName);
return factory.createPropertyAccessExpression(identifier, targetName);
}
}
}