Skip to content

Commit

Permalink
feat(ts-interface-generator): support non-default-export classes
Browse files Browse the repository at this point in the history
- create appropriate interface for classes which are not default
exports; this will make cases work when the default export is an
*instance* of the class (but it still requires the class itself to be
exported as named export, so the module augmentation can kick in).

- Add new way of writing finer-grained tests, so new cases can be
covered more easily

- Re-initialize base types for each generation to handle multiple
invocations in different type worlds properly - happens in tests

- Rename the "testdata" folder to "samples"
  • Loading branch information
akudev committed Sep 23, 2024
1 parent bf24e51 commit 20ccfa2
Show file tree
Hide file tree
Showing 26 changed files with 475 additions and 45 deletions.
15 changes: 8 additions & 7 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ packages/dts-generator/src/checkDtslint/dtslintConfig/openui5-tests.ts
packages/dts-generator/src/resources/core-preamble.d.ts
packages/dts-generator/temp/
packages/ts-interface-generator/dist/
packages/ts-interface-generator/src/test/testdata/sampleControl/SampleControl.gen.d.ts
packages/ts-interface-generator/src/test/testdata/sampleControl/SampleControl.ts
packages/ts-interface-generator/src/test/testdata/sampleManagedObject/SampleManagedObject.gen.d.ts
packages/ts-interface-generator/src/test/testdata/sampleManagedObject/SampleManagedObject.ts
packages/ts-interface-generator/src/test/testdata/sampleManagedObject/SampleAnotherManagedObject.gen.d.ts
packages/ts-interface-generator/src/test/testdata/sampleManagedObject/SampleAnotherManagedObject.ts
packages/ts-interface-generator/src/test/testdata/sampleWebComponent/SampleWebComponent.gen.d.ts
packages/ts-interface-generator/src/test/samples/sampleControl/SampleControl.gen.d.ts
packages/ts-interface-generator/src/test/samples/sampleControl/SampleControl.ts
packages/ts-interface-generator/src/test/samples/sampleManagedObject/SampleManagedObject.gen.d.ts
packages/ts-interface-generator/src/test/samples/sampleManagedObject/SampleManagedObject.ts
packages/ts-interface-generator/src/test/samples/sampleManagedObject/SampleAnotherManagedObject.gen.d.ts
packages/ts-interface-generator/src/test/samples/sampleManagedObject/SampleAnotherManagedObject.ts
packages/ts-interface-generator/src/test/samples/sampleWebComponent/SampleWebComponent.gen.d.ts
packages/ts-interface-generator/src/test/testcases/
test-packages
2 changes: 1 addition & 1 deletion packages/ts-interface-generator/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ module.exports = {
".eslintrc.js",
"someFile.js",
"*.gen.d.ts",
"src/test/testdata/sampleWebComponent/**/*",
"src/test/samples/sampleWebComponent/**/*",
],
};
125 changes: 98 additions & 27 deletions packages/ts-interface-generator/src/interfaceGenerationHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,21 @@ let ManagedObjectSymbol: ts.Symbol,
ElementSymbol: ts.Symbol,
ControlSymbol: ts.Symbol,
WebComponentSymbol: ts.Symbol;

// needs to be called to reset the base classes cache, so they are re-identified in the new type world
function resetBaseClasses() {
ManagedObjectSymbol = undefined;
ElementSymbol = undefined;
ControlSymbol = undefined;
WebComponentSymbol = undefined;
}

function interestingBaseClassForSymbol(
typeChecker: ts.TypeChecker,
symbol: ts.Symbol,
): "ManagedObject" | "Element" | "Control" | "WebComponent" | undefined {
if (!ManagedObjectSymbol) {
// cache - TODO: needs to be refreshed when the UI5 type definitions are updated during a run of the tool!
// cache (execution takes one-digit milliseconds) - TODO: does it need to be refreshed when the UI5 type definitions are updated during a run of the tool, or is the clearing from generateInterfaces sufficient?
// identify the symbols for the interesting classes
const managedObjectModuleDeclaration = typeChecker
.getAmbientModules()
Expand Down Expand Up @@ -132,6 +141,7 @@ function generateInterfaces(
interfaceText: string,
) => void = writeInterfaceFile,
) {
resetBaseClasses(); // typeChecker might be from a new type world
const mos = getManagedObjects(sourceFile, typeChecker);

// find out whether type version 1.115.1 or later is used, where "Event" is a class with generics (this influences what we need to generate)
Expand Down Expand Up @@ -184,6 +194,30 @@ function getManagedObjects(
sourceFile: ts.SourceFile,
typeChecker: ts.TypeChecker,
) {
// First find the default export (in contrast to named exports) of this ES module - we want to find top-level statements like:
// export default class MyControl extends Control {...} // direct export of the class
// export default MyControl; // export of a variable which holds the class
// we don't care about other default exports, including instances of the class:
// export default new MyControl(); // instance export
// and we are also not interested in named exports of the class here
// export class MyControl extends Control {...} // etc.
let defaultExport: ts.Identifier | ts.ClassDeclaration | undefined;
sourceFile.statements.forEach((statement) => {
if (
ts.isExportAssignment(statement) &&
ts.isIdentifier(statement.expression)
) {
defaultExport = statement.expression;
} else if (ts.isClassDeclaration(statement)) {
const hasDefaultModifier = statement.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
);
if (hasDefaultModifier) {
defaultExport = statement;
}
}
});

const managedObjects: ManagedObjectInfo[] = [];
sourceFile.statements.forEach((statement) => {
if (ts.isClassDeclaration(statement)) {
Expand Down Expand Up @@ -331,10 +365,20 @@ In any case, you need to make the parent class ${typeChecker.getFullyQualifiedNa
const constructorSignaturesAvailable =
checkConstructors(statement);

const className = statement.name ? statement.name.text : "";

// is this class a default export?
const isDefaultExport =
defaultExport &&
((ts.isIdentifier(defaultExport) &&
defaultExport.text === className) ||
defaultExport === statement);

// store the information about the identified ManagedObject/Control
managedObjects.push({
sourceFile,
className: statement.name ? statement.name.text : "",
className,
isDefaultExport,
classDeclaration: statement,
settingsTypeFullName,
interestingBaseClass,
Expand Down Expand Up @@ -700,6 +744,7 @@ function generateInterface(
{
sourceFile,
className,
isDefaultExport,
settingsTypeFullName,
interestingBaseClass,
constructorSignaturesAvailable,
Expand All @@ -708,6 +753,7 @@ function generateInterface(
| {
sourceFile: ts.SourceFile;
className: string;
isDefaultExport: boolean;
settingsTypeFullName: string;
interestingBaseClass:
| "ManagedObject"
Expand Down Expand Up @@ -801,6 +847,7 @@ function generateInterface(
const moduleName = path.basename(fileName, path.extname(fileName));
const ast = buildAST(
classInfo,
isDefaultExport,
sourceFile.fileName,
constructorSignaturesAvailable,
moduleName,
Expand All @@ -818,6 +865,7 @@ function generateInterface(

function buildAST(
classInfo: ClassInfo,
isDefaultExport: boolean,
classFileName: string,
constructorSignaturesAvailable: boolean,
moduleName: string,
Expand Down Expand Up @@ -882,29 +930,51 @@ function buildAST(

let myInterface;
if (parseFloat(ts.version) >= 4.8) {
myInterface = factory.createInterfaceDeclaration(
[
factory.createModifier(ts.SyntaxKind.ExportKeyword),
factory.createModifier(ts.SyntaxKind.DefaultKeyword),
],
classInfo.name,
undefined,
undefined,
methods,
);
if (isDefaultExport) {
myInterface = factory.createInterfaceDeclaration(
[
factory.createModifier(ts.SyntaxKind.ExportKeyword),
factory.createModifier(ts.SyntaxKind.DefaultKeyword),
],
classInfo.name,
undefined,
undefined,
methods,
);
} else {
myInterface = factory.createInterfaceDeclaration(
[], // no export needed for module augmentation when class is a named export in the original file!
classInfo.name,
undefined,
undefined,
methods,
);
}
} else {
myInterface = factory.createInterfaceDeclaration(
undefined,
[
factory.createModifier(ts.SyntaxKind.ExportKeyword),
factory.createModifier(ts.SyntaxKind.DefaultKeyword),
],
classInfo.name,
undefined,
undefined,
// @ts-ignore: below TS 4.8 there were more params
methods,
);
if (isDefaultExport) {
myInterface = factory.createInterfaceDeclaration(
undefined,
[
factory.createModifier(ts.SyntaxKind.ExportKeyword),
factory.createModifier(ts.SyntaxKind.DefaultKeyword),
],
classInfo.name,
undefined,
undefined,
// @ts-ignore: below TS 4.8 there were more params
methods,
);
} else {
myInterface = factory.createInterfaceDeclaration(
undefined,
[], // no export needed for module augmentation when class is a named export in the original file!
classInfo.name,
undefined,
undefined,
// @ts-ignore: below TS 4.8 there were more params
methods,
);
}
}
addLineBreakBefore(myInterface, 2);

Expand Down Expand Up @@ -945,8 +1015,9 @@ function buildAST(
statements.push(genericEventDefinitionModule);
}

// if needed, assemble the second module declaration
if (requiredImports.selfIsUsed) {
// If needed, assemble the second module declaration.
// In case the class is not a default export, the first module declaration will already be without export, so this second module declaration is not needed anyway
if (requiredImports.selfIsUsed && isDefaultExport) {
let myInterface2;
if (parseFloat(ts.version) >= 4.8) {
myInterface2 = factory.createInterfaceDeclaration(
Expand Down Expand Up @@ -988,7 +1059,7 @@ function buildAST(
ts.addSyntheticLeadingComment(
module2,
ts.SyntaxKind.SingleLineCommentTrivia,
" this duplicate interface without export is needed to avoid \"Cannot find name '" +
" this duplicate interface without export is needed to avoid \"Cannot find name '" + // TODO: does not seem to be needed any longer; investigate and try to reproduce
classInfo.name +
"'\" TypeScript errors above",
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { PropertyBindingInfo } from "sap/ui/base/ManagedObject";
import { $ControlSettings } from "sap/ui/core/Control";

declare module "./MyControl" {

/**
* Interface defining the settings object used in constructor calls
*/
interface $MyControlSettings extends $ControlSettings {

/**
* The text.
*
* @since 1.0
*/
text?: string | PropertyBindingInfo;
}

interface MyControl {

// property: text

/**
* Gets current value of property "text".
*
* The text.
*
* @since 1.0
*
* @returns Value of property "text"
*/
getText(): string;

/**
* Sets a new value for property "text".
*
* The text.
*
* @since 1.0
* When called with a value of "null" or "undefined", the default value of the property will be restored.
*
* @param text New value for property "text"
* @returns Reference to "this" in order to allow method chaining
*/
setText(text: string): this;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Control from "sap/ui/core/Control";
import { MetadataOptions } from "sap/ui/core/Element";
import RenderManager from "sap/ui/core/RenderManager";

/**
* This is my control.
*
* @namespace my
*/
export class MyControl extends Control {
static readonly metadata: MetadataOptions = {
properties: {
/**
* The text.
* @since 1.0
*/
text: "string",
},
};

static renderer = {
apiVersion: 2,
render: function (rm: RenderManager, control: MyControl) {
rm.openStart("div", control);
rm.openEnd();
// @ts-ignore this only works with the generated interface
rm.text(control.getText());
rm.close("div");
},
};
}

export default new MyControl();
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { PropertyBindingInfo } from "sap/ui/base/ManagedObject";
import { $ControlSettings } from "sap/ui/core/Control";

declare module "./MyControl" {

/**
* Interface defining the settings object used in constructor calls
*/
interface $MyControlSettings extends $ControlSettings {

/**
* The text.
*
* @since 1.0
*/
text?: string | PropertyBindingInfo;
}

export default interface MyControl {

// property: text

/**
* Gets current value of property "text".
*
* The text.
*
* @since 1.0
*
* @returns Value of property "text"
*/
getText(): string;

/**
* Sets a new value for property "text".
*
* The text.
*
* @since 1.0
* When called with a value of "null" or "undefined", the default value of the property will be restored.
*
* @param text New value for property "text"
* @returns Reference to "this" in order to allow method chaining
*/
setText(text: string): this;
}
}
Loading

0 comments on commit 20ccfa2

Please sign in to comment.