packages/jsii-reflect/lib/type-system.ts (253 lines of code) (raw):

import { findAssemblyFile, loadAssemblyFromFile } from '@jsii/spec'; import * as fs from 'fs-extra'; import * as path from 'path'; import { Assembly } from './assembly'; import { ClassType } from './class'; import { EnumType } from './enum'; import { InterfaceType } from './interface'; import { Method } from './method'; import { ModuleLike } from './module-like'; import { Property } from './property'; import { Type } from './type'; import { findDependencyDirectory, isBuiltinModule } from './util'; export class TypeSystem { /** * The "root" assemblies (ones that loaded explicitly via a "load" call). */ public readonly roots = new Array<Assembly>(); private readonly _assemblyLookup = new Map<string, Assembly>(); private readonly _cachedClasses = new Map<Assembly, readonly ClassType[]>(); private _locked = false; public get isLocked(): boolean { return this._locked; } /** * All assemblies in this type system. */ public get assemblies(): readonly Assembly[] { return Array.from(this._assemblyLookup.values()); } /** * Locks the TypeSystem from further changes * * Call this once all assemblies have been loaded. * This allows the reflection to optimize and cache certain expensive calls. */ public lock() { this._locked = true; } /** * Load all JSII dependencies of the given NPM package directory. * * The NPM package itself does *not* have to be a jsii package, and does * NOT have to declare a JSII dependency on any of the packages. */ public async loadNpmDependencies( packageRoot: string, options: { validate?: boolean } = {}, ): Promise<void> { const pkg = await fs.readJson(path.resolve(packageRoot, 'package.json')); for (const dep of dependenciesOf(pkg)) { if (isBuiltinModule(dep)) { continue; } // eslint-disable-next-line no-await-in-loop const depDir = await findDependencyDirectory(dep, packageRoot); // eslint-disable-next-line no-await-in-loop const depPkgJson = await fs.readJson(path.join(depDir, 'package.json')); if (!depPkgJson.jsii) { continue; } // eslint-disable-next-line no-await-in-loop await this.loadModule(depDir, options); } } /** * Loads a jsii module or a single .jsii file into the type system. * * If `fileOrDirectory` is a directory, it will be treated as a jsii npm module, * and its dependencies (as determined by its 'package.json' file) will be loaded * as well. * * If `fileOrDirectory` is a file, it will be treated as a single .jsii file. * No dependencies will be loaded. You almost never want this. * * Not validating makes the difference between loading assemblies with lots * of dependencies (such as app-delivery) in 90ms vs 3500ms. * * @param fileOrDirectory A .jsii file path or a module directory * @param validate Whether or not to validate the assembly while loading it. */ public async load( fileOrDirectory: string, options: { validate?: boolean } = {}, ) { if ((await fs.stat(fileOrDirectory)).isDirectory()) { return this.loadModule(fileOrDirectory, options); } return this.loadFile(fileOrDirectory, { ...options, isRoot: true }); } public async loadModule( dir: string, options: { validate?: boolean } = {}, ): Promise<Assembly> { const out = await _loadModule.call(this, dir, true); if (!out) { throw new Error(`Unable to load module from directory: ${dir}`); } return out; async function _loadModule( this: TypeSystem, moduleDirectory: string, isRoot = false, ) { const filePath = path.join(moduleDirectory, 'package.json'); const pkg = JSON.parse( await fs.readFile(filePath, { encoding: 'utf-8' }), ); if (!pkg.jsii) { throw new Error(`No "jsii" section in ${filePath}`); } // Load the assembly, but don't recurse if we already have an assembly with the same name. // Validation is not an insignificant time sink, and loading IS insignificant, so do a // load without validation first. This saves about 2/3rds of processing time. const asm = this.loadAssembly(findAssemblyFile(moduleDirectory), false); if (this.includesAssembly(asm.name)) { const existing = this.findAssembly(asm.name); if (existing.version !== asm.version) { throw new Error( `Conflicting versions of ${asm.name} in type system: previously loaded ${existing.version}, trying to load ${asm.version}`, ); } // Make sure that we mark this thing as root after all if it wasn't yet. if (isRoot) { this.addRoot(asm); } return existing; } if (options.validate !== false) { asm.validate(); } const root = this.addAssembly(asm, { isRoot }); // Using || instead of ?? because npmjs.com will alter the package.json file and possibly put `false` in pkg.bundleDependencies. // This is actually non compliant to the package.json specification, but that's how it is... const bundled: string[] = pkg.bundledDependencies ?? pkg.bundleDependencies ?? []; for (const name of dependenciesOf(pkg)) { if (bundled.includes(name)) { continue; } // eslint-disable-next-line no-await-in-loop const depDir = await findDependencyDirectory(name, moduleDirectory); // eslint-disable-next-line no-await-in-loop await _loadModule.call(this, depDir); } return root; } } public loadFile( file: string, options: { isRoot?: boolean; validate?: boolean } = {}, ) { const assembly = this.loadAssembly(file, options.validate !== false); return this.addAssembly(assembly, options); } public addAssembly(asm: Assembly, options: { isRoot?: boolean } = {}) { if (this.isLocked) { throw new Error('The typesystem has been locked from further changes'); } if (asm.system !== this) { throw new Error('Assembly has been created for different typesystem'); } if (!this._assemblyLookup.has(asm.name)) { this._assemblyLookup.set(asm.name, asm); } if (options.isRoot !== false) { this.addRoot(asm); } return asm; } /** * Determines whether this TypeSystem includes a given assembly. * * @param name the name of the assembly being looked for. */ public includesAssembly(name: string): boolean { return this._assemblyLookup.has(name); } public isRoot(name: string) { return this.roots.map((r) => r.name).includes(name); } public findAssembly(name: string) { const ret = this.tryFindAssembly(name); if (!ret) { throw new Error(`Assembly "${name}" not found`); } return ret; } public tryFindAssembly(name: string): Assembly | undefined { return this._assemblyLookup.get(name); } public findFqn(fqn: string): Type { const [assembly] = fqn.split('.'); const asm = this.findAssembly(assembly); return asm.findType(fqn); } public tryFindFqn(fqn: string): Type | undefined { const [assembly] = fqn.split('.'); const asm = this.tryFindAssembly(assembly); return asm?.tryFindType(fqn); } public findClass(fqn: string): ClassType { const type = this.findFqn(fqn); if (!(type instanceof ClassType)) { throw new Error(`FQN ${fqn} is not a class`); } return type; } public findInterface(fqn: string): InterfaceType { const type = this.findFqn(fqn); if (!(type instanceof InterfaceType)) { throw new Error(`FQN ${fqn} is not an interface`); } return type; } public findEnum(fqn: string): EnumType { const type = this.findFqn(fqn); if (!(type instanceof EnumType)) { throw new Error(`FQN ${fqn} is not an enum`); } return type; } /** * All methods in the type system. */ public get methods() { const getMethods = (mod: ModuleLike): readonly Method[] => { return [ ...flatMap(mod.submodules, getMethods), ...flatMap(mod.interfaces, (iface) => iface.ownMethods), ...flatMap(mod.classes, (clazz) => clazz.ownMethods), ]; }; return flatMap(this.assemblies, getMethods); } /** * All properties in the type system. */ public get properties() { const getProperties = (mod: ModuleLike): readonly Property[] => { return [ ...flatMap(mod.submodules, getProperties), ...flatMap(mod.interfaces, (iface) => iface.ownProperties), ...flatMap(mod.classes, (clazz) => clazz.ownProperties), ]; }; return flatMap(this.assemblies, getProperties); } /** * All classes in the type system. */ public get classes(): readonly ClassType[] { const out = new Array<ClassType>(); this.assemblies.forEach((a) => { // Cache the class list for each assembly. We can't use @memoized for this method since new // assemblies can be added between calls, via loadModule(). if (!this._cachedClasses.has(a)) { this._cachedClasses.set( a, collectTypes(a, (item) => item.classes), ); } out.push(...this._cachedClasses.get(a)!); }); return out; } /** * All interfaces in the type system. */ public get interfaces(): readonly InterfaceType[] { const out = new Array<InterfaceType>(); this.assemblies.forEach((a) => { out.push(...collectTypes(a, (item) => item.interfaces)); }); return out; } /** * All enums in the type system. */ public get enums(): readonly EnumType[] { const out = new Array<EnumType>(); this.assemblies.forEach((a) => { out.push(...collectTypes(a, (item) => item.enums)); }); return out; } /** * Load an assembly without adding it to the typesystem * @param file Assembly file to load * @param validate Whether to validate the assembly or just assume it matches the schema */ private loadAssembly(file: string, validate = true) { const contents = loadAssemblyFromFile(file, validate); return new Assembly(this, contents); } private addRoot(asm: Assembly) { if (!this.roots.some((r) => r.name === asm.name)) { this.roots.push(asm); } } } function dependenciesOf(packageJson: any) { const deps = new Set<string>(); Object.keys(packageJson.dependencies ?? {}).forEach(deps.add.bind(deps)); Object.keys(packageJson.peerDependencies ?? {}).forEach(deps.add.bind(deps)); return Array.from(deps); } function collectTypes<T extends Type>( module: ModuleLike, getter: (module: ModuleLike) => readonly T[], ): readonly T[] { const result = new Array<T>(); for (const submodule of module.submodules) { result.push(...collectTypes(submodule, getter)); } result.push(...getter(module)); return result; } function flatMap<T, R>( collection: readonly T[], mapper: (value: T) => readonly R[], ): readonly R[] { return collection .map(mapper) .reduce((acc, elt) => acc.concat(elt), new Array<R>()); }