packages/jsii-pacmak/lib/generator.ts (448 lines of code) (raw):
import * as spec from '@jsii/spec';
import * as clone from 'clone';
import { CodeMaker } from 'codemaker';
import * as crypto from 'crypto';
import * as fs from 'fs-extra';
import * as reflect from 'jsii-reflect';
import * as path from 'path';
import { VERSION_DESC } from './version';
/**
* Options for the code generator framework.
*/
export interface GeneratorOptions {
/**
* If this property is set to 'true', union properties are "expanded" into multiple
* properties, each with a different type and a postfix based on the type name. This
* can be used by languages that don't have support for union types (e.g. Java).
*/
expandUnionProperties?: boolean;
/**
* If this property is set to 'true', methods that have optional arguments are duplicated
* and overloads are created with all parameters.
*/
generateOverloadsForMethodWithOptionals?: boolean;
/**
* If this property is set, the generator will add "Base" to abstract class names
*/
addBasePostfixToAbstractClassNames?: boolean;
/**
* If this property is set, the generator will add runtime type checking code in places
* where compile-time type checking is not possible.
*/
runtimeTypeChecking: boolean;
}
export interface IGenerator {
/**
*
* @param fingerprint
*/
generate(fingerprint: boolean): void;
/**
* Load a module into the generator.
* @param packageDir is the root directory of the module.
*/
load(packageDir: string, assembly: reflect.Assembly): Promise<void>;
/**
* Determine if the generated artifacts for this generator are already up-to-date.
*
* @param outDir the directory where generated artifacts would be placed.
* @param tarball the tarball of the bundled node library
* @param legalese the license and notice file contents (if any)
*
* @return ``true`` if no generation is necessary
*/
upToDate(outDir: string): Promise<boolean>;
/**
* Saves the generated code in the provided output directory.
*
* @param outdir the directory in which to place generated code.
* @param tarball the bundled npm library backing the generated code.
* @param legalese the LICENSE & NOTICE contents for this package.
*/
save(outdir: string, tarball: string, legalese: Legalese): Promise<any>;
}
export interface Legalese {
/**
* The text of the SPDX license associated with this package, if any.
*/
readonly license?: string;
/**
* The contents of the NOTICE file for this package, if any.
*/
readonly notice?: string;
}
/**
* Abstract base class for jsii package generators.
* Given a jsii module, it will invoke "events" to emit various elements.
*/
export abstract class Generator implements IGenerator {
private readonly excludeTypes = new Array<string>();
protected readonly code = new CodeMaker();
private _assembly?: spec.Assembly;
protected _reflectAssembly?: reflect.Assembly;
private fingerprint?: string;
public constructor(private readonly options: GeneratorOptions) {}
protected get runtimeTypeChecking() {
return this.options.runtimeTypeChecking;
}
protected get assembly(): spec.Assembly {
if (!this._assembly) {
throw new Error(
'No assembly has been loaded! The #load() method must be called first!',
);
}
return this._assembly;
}
public get reflectAssembly(): reflect.Assembly {
if (!this._reflectAssembly) {
throw new Error('Call load() first');
}
return this._reflectAssembly;
}
public get metadata() {
return { fingerprint: this.fingerprint };
}
public async load(
_packageRoot: string,
assembly: reflect.Assembly,
): Promise<void> {
this._reflectAssembly = assembly;
this._assembly = assembly.spec;
// Including the version of jsii-pacmak in the fingerprint, as a new version may imply different code generation.
this.fingerprint = crypto
.createHash('sha256')
.update(VERSION_DESC)
.update('\0')
.update(this.assembly.fingerprint)
.digest('base64');
return Promise.resolve();
}
/**
* Runs the generator (in-memory).
*/
public generate(fingerprint: boolean): void {
this.onBeginAssembly(this.assembly, fingerprint);
this.visit(spec.NameTree.of(this.assembly));
this.onEndAssembly(this.assembly, fingerprint);
}
public async upToDate(_: string): Promise<boolean> {
return Promise.resolve(false);
}
/**
* Returns the file name of the assembly resource as it is going to be saved.
*/
protected getAssemblyFileName() {
let name = this.assembly.name;
const parts = name.split('/');
if (parts.length === 1) {
name = parts[0];
} else if (parts.length === 2 && parts[0].startsWith('@')) {
name = parts[1];
} else {
throw new Error(
'Malformed assembly name. Expecting either <name> or @<scope>/<name>',
);
}
return `${name}@${this.assembly.version}.jsii.tgz`;
}
/**
* Saves all generated files to an output directory, creating any subdirs if needed.
*/
public async save(
outdir: string,
tarball: string,
{ license, notice }: Legalese,
) {
const assemblyDir = this.getAssemblyOutputDir(this.assembly);
if (assemblyDir) {
const fullPath = path.resolve(
path.join(outdir, assemblyDir, this.getAssemblyFileName()),
);
await fs.mkdirp(path.dirname(fullPath));
await fs.copy(tarball, fullPath, { overwrite: true });
if (license) {
await fs.writeFile(path.resolve(outdir, 'LICENSE'), license, {
encoding: 'utf8',
});
}
if (notice) {
await fs.writeFile(path.resolve(outdir, 'NOTICE'), notice, {
encoding: 'utf8',
});
}
}
return this.code.save(outdir);
}
//
// Bundled assembly
// jsii modules should bundle the assembly itself as a resource and use the load() kernel API to load it.
//
/**
* Returns the destination directory for the assembly file.
*/
protected getAssemblyOutputDir(_mod: spec.Assembly): string | undefined {
return undefined;
}
//
// Assembly
protected onBeginAssembly(_assm: spec.Assembly, _fingerprint: boolean) {
/* noop */
}
protected onEndAssembly(_assm: spec.Assembly, _fingerprint: boolean) {
/* noop */
}
//
// Namespaces
protected onBeginNamespace(_ns: string) {
/* noop */
}
protected onEndNamespace(_ns: string) {
/* noop */
}
//
// Classes
protected onBeginClass(_cls: spec.ClassType, _abstract: boolean | undefined) {
/* noop */
}
protected onEndClass(_cls: spec.ClassType) {
/* noop */
}
//
// Interfaces
protected abstract onBeginInterface(ifc: spec.InterfaceType): void;
protected abstract onEndInterface(ifc: spec.InterfaceType): void;
protected abstract onInterfaceMethod(
ifc: spec.InterfaceType,
method: spec.Method,
): void;
protected abstract onInterfaceMethodOverload(
ifc: spec.InterfaceType,
overload: spec.Method,
originalMethod: spec.Method,
): void;
protected abstract onInterfaceProperty(
ifc: spec.InterfaceType,
prop: spec.Property,
): void;
//
// Initializers (constructos)
protected onInitializer(
_cls: spec.ClassType,
_initializer: spec.Initializer,
) {
/* noop */
}
protected onInitializerOverload(
_cls: spec.ClassType,
_overload: spec.Initializer,
_originalInitializer: spec.Initializer,
) {
/* noop */
}
//
// Properties
protected onBeginProperties(_cls: spec.ClassType) {
/* noop */
}
protected abstract onProperty(cls: spec.ClassType, prop: spec.Property): void;
protected abstract onStaticProperty(
cls: spec.ClassType,
prop: spec.Property,
): void;
protected onEndProperties(_cls: spec.ClassType) {
/* noop */
}
//
// Union Properties
// Those are properties that can accept more than a single type (i.e. String | Token). If the option `expandUnionProperties` is enabled
// instead of onUnionProperty, the method onExpandedUnionProperty will be called for each of the types defined in the property.
// `primaryName` indicates the original name of the union property (without the 'AsXxx' postfix).
protected abstract onUnionProperty(
cls: spec.ClassType,
prop: spec.Property,
union: spec.UnionTypeReference,
): void;
protected onExpandedUnionProperty(
_cls: spec.ClassType,
_prop: spec.Property,
_primaryName: string,
): void {
return;
}
//
// Methods
// onMethodOverload is triggered if the option `generateOverloadsForMethodWithOptionals` is enabled for each overload of the original method.
// The original method will be emitted via onMethod.
protected onBeginMethods(_cls: spec.ClassType) {
/* noop */
}
protected abstract onMethod(cls: spec.ClassType, method: spec.Method): void;
protected abstract onMethodOverload(
cls: spec.ClassType,
overload: spec.Method,
originalMethod: spec.Method,
): void;
protected abstract onStaticMethod(
cls: spec.ClassType,
method: spec.Method,
): void;
protected abstract onStaticMethodOverload(
cls: spec.ClassType,
overload: spec.Method,
originalMethod: spec.Method,
): void;
protected onEndMethods(_cls: spec.ClassType) {
/* noop */
}
//
// Enums
protected onBeginEnum(_enm: spec.EnumType) {
/* noop */
}
protected onEndEnum(_enm: spec.EnumType) {
/* noop */
}
protected onEnumMember(_enm: spec.EnumType, _member: spec.EnumMember) {
/* noop */
}
//
// Fields
// Can be used to implements properties backed by fields in cases where we want to generate "native" classes.
// The default behavior is that properties do not have backing fields.
protected hasField(_cls: spec.ClassType, _prop: spec.Property): boolean {
return false;
}
protected onField(
_cls: spec.ClassType,
_prop: spec.Property,
_union?: spec.UnionTypeReference,
) {
/* noop */
}
private visit(node: spec.NameTree, names = new Array<string>()) {
const namespace =
!node.fqn && names.length > 0 ? names.join('.') : undefined;
if (namespace) {
this.onBeginNamespace(namespace);
}
const visitChildren = () => {
Object.keys(node.children)
.sort()
.forEach((name) => {
this.visit(node.children[name], names.concat(name));
});
};
if (node.fqn) {
const type = this.assembly.types?.[node.fqn];
if (!type) {
throw new Error(`Malformed jsii file. Cannot find type: ${node.fqn}`);
}
if (!this.shouldExcludeType(type.name)) {
switch (type.kind) {
case spec.TypeKind.Class:
const classSpec = type as spec.ClassType;
const abstract = classSpec.abstract;
if (abstract && this.options.addBasePostfixToAbstractClassNames) {
this.addAbstractPostfixToClassName(classSpec);
}
this.onBeginClass(classSpec, abstract);
this.visitClass(classSpec);
visitChildren();
this.onEndClass(classSpec);
break;
case spec.TypeKind.Enum:
const enumSpec = type as spec.EnumType;
this.onBeginEnum(enumSpec);
this.visitEnum(enumSpec);
visitChildren();
this.onEndEnum(enumSpec);
break;
case spec.TypeKind.Interface:
const interfaceSpec = type as spec.InterfaceType;
this.onBeginInterface(interfaceSpec);
this.visitInterface(interfaceSpec);
visitChildren();
this.onEndInterface(interfaceSpec);
break;
default:
throw new Error(`Unsupported type kind: ${(type as any).kind}`);
}
}
} else {
visitChildren();
}
if (namespace) {
this.onEndNamespace(namespace);
}
}
/**
* Adds a postfix ("XxxBase") to the class name to indicate it is abstract.
*/
private addAbstractPostfixToClassName(cls: spec.ClassType) {
cls.name = `${cls.name}Base`;
const components = cls.fqn.split('.');
cls.fqn = components
.map((x, i) => (i < components.length - 1 ? x : `${x}Base`))
.join('.');
}
protected excludeType(...names: string[]) {
for (const n of names) {
this.excludeTypes.push(n);
}
}
private shouldExcludeType(name: string) {
return this.excludeTypes.includes(name);
}
/**
* Returns all the method overloads needed to satisfy optional arguments.
* For example, for the method `foo(bar: string, hello?: number, world?: number)`
* this method will return:
* - foo(bar: string)
* - foo(bar: string, hello: number)
*
* Notice that the method that contains all the arguments will not be returned.
*/
protected createOverloadsForOptionals<
T extends spec.Method | spec.Initializer,
>(method: T) {
const overloads = new Array<T>();
// if option disabled, just return the empty array.
if (
!this.options.generateOverloadsForMethodWithOptionals ||
!method.parameters
) {
return overloads;
}
//
// pop an argument from the end of the parameter list.
// if it is an optional argument, clone the method without that parameter.
// continue until we reach a non optional param or no parameters left.
//
const remaining: spec.Parameter[] = clone(method.parameters);
let next: spec.Parameter | undefined;
next = remaining.pop();
// Parameter is optional if it's type is optional, and all subsequent parameters are optional/variadic
while (next?.optional) {
// clone the method but set the parameter list based on the remaining set of parameters
const cloned: T = clone(method);
cloned.parameters = clone(remaining);
overloads.push(cloned);
// pop the next parameter
next = remaining.pop();
}
return overloads;
}
private visitInterface(ifc: spec.InterfaceType) {
if (ifc.properties) {
ifc.properties.forEach((prop) => {
this.onInterfaceProperty(ifc, prop);
});
}
if (ifc.methods) {
ifc.methods.forEach((method) => {
this.onInterfaceMethod(ifc, method);
for (const overload of this.createOverloadsForOptionals(method)) {
this.onInterfaceMethodOverload(ifc, overload, method);
}
});
}
}
private visitClass(cls: spec.ClassType) {
const initializer = cls.initializer;
if (initializer) {
this.onInitializer(cls, initializer);
// if method has optional arguments and
for (const overload of this.createOverloadsForOptionals(initializer)) {
this.onInitializerOverload(cls, overload, initializer);
}
}
// if running in 'pure' mode and the class has methods, emit them as abstract methods.
if (cls.methods) {
this.onBeginMethods(cls);
cls.methods.forEach((method) => {
if (!method.static) {
this.onMethod(cls, method);
for (const overload of this.createOverloadsForOptionals(method)) {
this.onMethodOverload(cls, overload, method);
}
} else {
this.onStaticMethod(cls, method);
for (const overload of this.createOverloadsForOptionals(method)) {
this.onStaticMethodOverload(cls, overload, method);
}
}
});
this.onEndMethods(cls);
}
if (cls.properties) {
this.onBeginProperties(cls);
cls.properties.forEach((prop) => {
if (this.hasField(cls, prop)) {
this.onField(
cls,
prop,
spec.isUnionTypeReference(prop.type) ? prop.type : undefined,
);
}
});
cls.properties.forEach((prop) => {
if (!spec.isUnionTypeReference(prop.type)) {
if (!prop.static) {
this.onProperty(cls, prop);
} else {
this.onStaticProperty(cls, prop);
}
} else {
// okay, this is a union. some languages support unions (mostly the dynamic ones) and some will need some help
// if `expandUnionProperties` is set, we will "expand" each property that has a union type into multiple properties
// and postfix their name with the type name (i.e. FooAsToken).
// first, emit a property for the union, for languages that support unions.
this.onUnionProperty(cls, prop, prop.type);
// if require, we also "expand" the union for languages that don't support unions.
if (this.options.expandUnionProperties) {
for (const [index, type] of prop.type.union.types.entries()) {
// create a clone of this property
const propClone = clone(prop);
const primary = this.isPrimaryExpandedUnionProperty(
prop.type,
index,
);
const propertyName = primary
? prop.name
: `${prop.name}As${this.displayNameForType(type)}`;
propClone.type = type;
propClone.optional = prop.optional;
propClone.name = propertyName;
this.onExpandedUnionProperty(cls, propClone, prop.name);
}
}
}
});
this.onEndProperties(cls);
}
}
/**
* Magical heuristic to determine which type in a union is the primary type. The primary type will not have
* a postfix with the name of the type attached to the expanded property name.
*
* The primary type is determined according to the following rules (first match):
* 1. The first primitive type
* 2. The first primitive collection
* 3. No primary
*/
protected isPrimaryExpandedUnionProperty(
ref: spec.UnionTypeReference | undefined,
index: number,
) {
if (!ref) {
return false;
}
return (
index ===
ref.union.types.findIndex((t) => {
if (spec.isPrimitiveTypeReference(t)) {
return true;
}
return false;
})
);
}
private visitEnum(enumSpec: spec.EnumType) {
if (enumSpec.members) {
enumSpec.members.forEach((spec) => this.onEnumMember(enumSpec, spec));
}
}
private displayNameForType(type: spec.TypeReference): string {
// last name from FQN
if (spec.isNamedTypeReference(type)) {
const comps = type.fqn.split('.');
const last = comps[comps.length - 1];
return this.code.toPascalCase(last);
}
// primitive name
if (spec.isPrimitiveTypeReference(type)) {
return this.code.toPascalCase(type.primitive);
}
// ListOfX or MapOfX
const coll = spec.isCollectionTypeReference(type) && type.collection;
if (coll) {
return `${this.code.toPascalCase(coll.kind)}Of${this.displayNameForType(
coll.elementtype,
)}`;
}
const union = spec.isUnionTypeReference(type) && type.union;
if (union) {
return union.types.map((t) => this.displayNameForType(t)).join('Or');
}
throw new Error(
`Cannot determine display name for type: ${JSON.stringify(type)}`,
);
}
/**
* Looks up a jsii module in the dependency tree.
* @param name The name of the jsii module to look up
*/
protected findModule(name: string): spec.AssemblyConfiguration {
// if this is the current module, return it
if (this.assembly.name === name) {
return this.assembly;
}
const found = (this.assembly.dependencyClosure ?? {})[name];
if (found) {
return found;
}
throw new Error(
`Unable to find module ${name} as a dependency of ${this.assembly.name}`,
);
}
protected findType(fqn: string): spec.Type {
const ret = this.reflectAssembly.system.tryFindFqn(fqn);
if (!ret) {
throw new Error(
`Cannot find type '${fqn}' either as internal or external type`,
);
}
return ret.spec;
}
}