Skip to content

Commit 569ed0b

Browse files
authored
feat(ts-interface-generator): support non-default-export classes (#476)
- 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" - add more testcases - adapt README
1 parent bf24e51 commit 569ed0b

34 files changed

+2586
-73
lines changed

.prettierignore

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ packages/dts-generator/src/checkDtslint/dtslintConfig/openui5-tests.ts
55
packages/dts-generator/src/resources/core-preamble.d.ts
66
packages/dts-generator/temp/
77
packages/ts-interface-generator/dist/
8-
packages/ts-interface-generator/src/test/testdata/sampleControl/SampleControl.gen.d.ts
9-
packages/ts-interface-generator/src/test/testdata/sampleControl/SampleControl.ts
10-
packages/ts-interface-generator/src/test/testdata/sampleManagedObject/SampleManagedObject.gen.d.ts
11-
packages/ts-interface-generator/src/test/testdata/sampleManagedObject/SampleManagedObject.ts
12-
packages/ts-interface-generator/src/test/testdata/sampleManagedObject/SampleAnotherManagedObject.gen.d.ts
13-
packages/ts-interface-generator/src/test/testdata/sampleManagedObject/SampleAnotherManagedObject.ts
14-
packages/ts-interface-generator/src/test/testdata/sampleWebComponent/SampleWebComponent.gen.d.ts
8+
packages/ts-interface-generator/src/test/samples/sampleControl/SampleControl.gen.d.ts
9+
packages/ts-interface-generator/src/test/samples/sampleControl/SampleControl.ts
10+
packages/ts-interface-generator/src/test/samples/sampleManagedObject/SampleManagedObject.gen.d.ts
11+
packages/ts-interface-generator/src/test/samples/sampleManagedObject/SampleManagedObject.ts
12+
packages/ts-interface-generator/src/test/samples/sampleManagedObject/SampleAnotherManagedObject.gen.d.ts
13+
packages/ts-interface-generator/src/test/samples/sampleManagedObject/SampleAnotherManagedObject.ts
14+
packages/ts-interface-generator/src/test/samples/sampleWebComponent/SampleWebComponent.gen.d.ts
15+
packages/ts-interface-generator/src/test/testcases/
1516
test-packages

packages/ts-interface-generator/.eslintrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,6 @@ module.exports = {
3333
".eslintrc.js",
3434
"someFile.js",
3535
"*.gen.d.ts",
36-
"src/test/testdata/sampleWebComponent/**/*",
36+
"src/test/samples/sampleWebComponent/**/*",
3737
],
3838
};

packages/ts-interface-generator/README.md

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,26 @@ This is the list of available command-line arguments, including the ones already
5555
- `--loglevel`: Set the console logging verbosity; options are: `error`, `warn`, `info`, `debug`, `trace`; default level is `info`
5656
- `--jsdoc`: Set the amount of JSDoc which should be generated; options are: `none`, `minimal`, `verbose`; default is `verbose`: by default, the JSDoc documentation written in the control metadata for the properties, aggregations etc. is added to the generated methods, plus generic documentation for the `@param` and `@returns` tags as well as some additional generic documentation like for default values (if any). By setting `--jsdoc none` or `--jsdoc minimal` you can decide to omit all JSDoc or to only add the JSDoc written in the control.
5757

58+
## Why?
59+
60+
When developing UI5 controls, the control metadata declares properties, aggregations, events etc. The methods related to these (like `getText()` for a `text` property or `attachPress(...)` for a `press` event) do not need to be implemented - they are generated by the UI5 framework at runtime.
61+
62+
However, as the TypeScript environment does not know about these methods, a call to any of them will be marked as error in the source code. And on top of this, there is also no code completion for these methods and no type information and in-place documentation for parameters and return values.
63+
64+
This is a problem for application code using controls developed in TypeScript as well as for the development of these controls in TypeScript. Both development scenarios involve calling those API methods which are not known to the TypeScript environment. Thus, there needs to be a way to make TypeScript aware of them.
65+
66+
(Remark: the controls shipped with the UI5 framework are implemented in JavaScript and a complete set of TypeScript type definitions is created during the framework build from the JSDoc comments. So both facets of the problem do not apply to them.)
67+
68+
## How it Works
69+
70+
This tool scans all TypeScript source files for <b>top-level definitions of classes</b> inheriting from `sap.ui.base.ManagedObject` (in most cases those might be sub-classes of `sap.ui.core.Control`, so they will be called "controls" for simplicity).
71+
72+
For any such control, the metadata is parsed, analyzed, and a new TypeScript file is constructed, which contains an interface declaration with the methods generated by UI5 at runtime. This generated interface gets merged with the already existing code using TypeScript's [Declaration Merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) concept.
73+
74+
Unfortunately, these separate interface declarations cannot define new constructors (see e.g. [this related TS issue](https://github.com/microsoft/TypeScript/issues/2957)). Hence those must be manually added to each control (one-time effort, pasting 3 lines of code). The interface generator writes the required lines to the console.
75+
76+
Oh, and the tool itself is implemented in TypeScript because TypeScript makes development more efficient. ;-)
77+
5878
## Limitations
5979

6080
See the [TODO](#TODO) section for examples of features not yet implemented.
@@ -75,42 +95,19 @@ To detect whether the required constructor signatures are already present in the
7595

7696
### Second(+)-level Inheritance
7797

78-
When there are at least two levels of custom controls (inheriting from each other), there is the error message: `Class static side 'typeof SomeOtherControl' incorrectly extends base class static side 'typeof MyControl'. The types of 'metadata.properties' are incompatible between these types.`
79-
80-
As a workaround, you can assign a type to the `metadata` field in the parent class. Using type `object` is sufficient, but it can also be more specific:
98+
When there are at least two levels of custom controls (inheriting from each other) and you get the error message: `Class static side 'typeof SomeOtherControl' incorrectly extends base class static side 'typeof MyControl'. The types of 'metadata.properties' are incompatible between these types.`, then you have missed to assign the `MetadataOptions` type to the `metadata` field in the parent class. (Before UI5 version 1.110, you can use the type `object` instead):
8199

82100
```ts
83-
static readonly metadata: object = {
101+
static readonly metadata: MetadataOptions = {
84102
...
85103
```
86104
87105
See [#338](https://github.com/SAP/ui5-typescript/issues/338) for more details.
88106
89-
## Why?
90-
91-
When developing UI5 controls, the control metadata declares properties, aggregations, events etc. The methods related to these (like `getText()` for a `text` property or `attachPress(...)` for a `press` event) do not need to be implemented - they are generated by the UI5 framework at runtime.
92-
93-
However, as the TypeScript environment does not know about these methods, a call to any of them will be marked as error in the source code. And on top of this, there is also no code completion for these methods and no type information and in-place documentation for parameters and return values.
94-
95-
This is a problem for application code using controls developed in TypeScript as well as for the development of these controls in TypeScript. Both development scenarios involve calling those API methods which are not known to the TypeScript environment. Thus, there needs to be a way to make TypeScript aware of them.
96-
97-
(Remark: the controls shipped with the UI5 framework are implemented in JavaScript and a complete set of TypeScript type definitions is created during the framework build from the JSDoc comments. So both facets of the problem do not apply to them.)
98-
99-
## How it Works
100-
101-
This tool scans all TypeScript source files for <b>top-level definitions of classes</b> inheriting from `sap.ui.base.ManagedObject` (in most cases those might be sub-classes of `sap.ui.core.Control`, so they will be called "controls" for simplicity).
102-
103-
For any such control, the metadata is parsed, analyzed, and a new TypeScript file is constructed, which contains an interface declaration with the methods generated by UI5 at runtime.
104-
105-
Unfortunately, these separate interface declarations cannot define new constructors (see e.g. [this related TS issue](https://github.com/microsoft/TypeScript/issues/2957)). Hence those must be manually added to each control (one-time effort, pasting 3 lines of code). The interface generator writes the required lines to the console.
106-
107-
Oh, and the tool itself is implemented in TypeScript because TypeScript makes development more efficient. ;-)
108-
109107
## TODO
110108
111109
- make sure watch mode does it right (also run on deletion? Delete interfaces before-creating? Only create interfaces for updated files?)
112110
- consider further information like deprecation etc.
113-
- it is probably required to check whether the control class being handled is the default export or a named export. Right now it is assumed that it is the default export. Other cases are not tested and likely not working.
114111
- ...
115112
116113
## Support

packages/ts-interface-generator/src/interfaceGenerationHelper.ts

Lines changed: 98 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,21 @@ let ManagedObjectSymbol: ts.Symbol,
2222
ElementSymbol: ts.Symbol,
2323
ControlSymbol: ts.Symbol,
2424
WebComponentSymbol: ts.Symbol;
25+
26+
// needs to be called to reset the base classes cache, so they are re-identified in the new type world
27+
function resetBaseClasses() {
28+
ManagedObjectSymbol = undefined;
29+
ElementSymbol = undefined;
30+
ControlSymbol = undefined;
31+
WebComponentSymbol = undefined;
32+
}
33+
2534
function interestingBaseClassForSymbol(
2635
typeChecker: ts.TypeChecker,
2736
symbol: ts.Symbol,
2837
): "ManagedObject" | "Element" | "Control" | "WebComponent" | undefined {
2938
if (!ManagedObjectSymbol) {
30-
// cache - TODO: needs to be refreshed when the UI5 type definitions are updated during a run of the tool!
39+
// 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?
3140
// identify the symbols for the interesting classes
3241
const managedObjectModuleDeclaration = typeChecker
3342
.getAmbientModules()
@@ -132,6 +141,7 @@ function generateInterfaces(
132141
interfaceText: string,
133142
) => void = writeInterfaceFile,
134143
) {
144+
resetBaseClasses(); // typeChecker might be from a new type world
135145
const mos = getManagedObjects(sourceFile, typeChecker);
136146

137147
// 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)
@@ -184,6 +194,30 @@ function getManagedObjects(
184194
sourceFile: ts.SourceFile,
185195
typeChecker: ts.TypeChecker,
186196
) {
197+
// First find the default export (in contrast to named exports) of this ES module - we want to find top-level statements like:
198+
// export default class MyControl extends Control {...} // direct export of the class
199+
// export default MyControl; // export of a variable which holds the class
200+
// we don't care about other default exports, including instances of the class:
201+
// export default new MyControl(); // instance export
202+
// and we are also not interested in named exports of the class here
203+
// export class MyControl extends Control {...} // etc.
204+
let defaultExport: ts.Identifier | ts.ClassDeclaration | undefined;
205+
sourceFile.statements.forEach((statement) => {
206+
if (
207+
ts.isExportAssignment(statement) &&
208+
ts.isIdentifier(statement.expression)
209+
) {
210+
defaultExport = statement.expression;
211+
} else if (ts.isClassDeclaration(statement)) {
212+
const hasDefaultModifier = statement.modifiers?.some(
213+
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
214+
);
215+
if (hasDefaultModifier) {
216+
defaultExport = statement;
217+
}
218+
}
219+
});
220+
187221
const managedObjects: ManagedObjectInfo[] = [];
188222
sourceFile.statements.forEach((statement) => {
189223
if (ts.isClassDeclaration(statement)) {
@@ -331,10 +365,20 @@ In any case, you need to make the parent class ${typeChecker.getFullyQualifiedNa
331365
const constructorSignaturesAvailable =
332366
checkConstructors(statement);
333367

368+
const className = statement.name ? statement.name.text : "";
369+
370+
// is this class a default export?
371+
const isDefaultExport =
372+
defaultExport &&
373+
((ts.isIdentifier(defaultExport) &&
374+
defaultExport.text === className) ||
375+
defaultExport === statement);
376+
334377
// store the information about the identified ManagedObject/Control
335378
managedObjects.push({
336379
sourceFile,
337-
className: statement.name ? statement.name.text : "",
380+
className,
381+
isDefaultExport,
338382
classDeclaration: statement,
339383
settingsTypeFullName,
340384
interestingBaseClass,
@@ -700,6 +744,7 @@ function generateInterface(
700744
{
701745
sourceFile,
702746
className,
747+
isDefaultExport,
703748
settingsTypeFullName,
704749
interestingBaseClass,
705750
constructorSignaturesAvailable,
@@ -708,6 +753,7 @@ function generateInterface(
708753
| {
709754
sourceFile: ts.SourceFile;
710755
className: string;
756+
isDefaultExport: boolean;
711757
settingsTypeFullName: string;
712758
interestingBaseClass:
713759
| "ManagedObject"
@@ -801,6 +847,7 @@ function generateInterface(
801847
const moduleName = path.basename(fileName, path.extname(fileName));
802848
const ast = buildAST(
803849
classInfo,
850+
isDefaultExport,
804851
sourceFile.fileName,
805852
constructorSignaturesAvailable,
806853
moduleName,
@@ -818,6 +865,7 @@ function generateInterface(
818865

819866
function buildAST(
820867
classInfo: ClassInfo,
868+
isDefaultExport: boolean,
821869
classFileName: string,
822870
constructorSignaturesAvailable: boolean,
823871
moduleName: string,
@@ -882,29 +930,51 @@ function buildAST(
882930

883931
let myInterface;
884932
if (parseFloat(ts.version) >= 4.8) {
885-
myInterface = factory.createInterfaceDeclaration(
886-
[
887-
factory.createModifier(ts.SyntaxKind.ExportKeyword),
888-
factory.createModifier(ts.SyntaxKind.DefaultKeyword),
889-
],
890-
classInfo.name,
891-
undefined,
892-
undefined,
893-
methods,
894-
);
933+
if (isDefaultExport) {
934+
myInterface = factory.createInterfaceDeclaration(
935+
[
936+
factory.createModifier(ts.SyntaxKind.ExportKeyword),
937+
factory.createModifier(ts.SyntaxKind.DefaultKeyword),
938+
],
939+
classInfo.name,
940+
undefined,
941+
undefined,
942+
methods,
943+
);
944+
} else {
945+
myInterface = factory.createInterfaceDeclaration(
946+
[], // no export needed for module augmentation when class is a named export in the original file!
947+
classInfo.name,
948+
undefined,
949+
undefined,
950+
methods,
951+
);
952+
}
895953
} else {
896-
myInterface = factory.createInterfaceDeclaration(
897-
undefined,
898-
[
899-
factory.createModifier(ts.SyntaxKind.ExportKeyword),
900-
factory.createModifier(ts.SyntaxKind.DefaultKeyword),
901-
],
902-
classInfo.name,
903-
undefined,
904-
undefined,
905-
// @ts-ignore: below TS 4.8 there were more params
906-
methods,
907-
);
954+
if (isDefaultExport) {
955+
myInterface = factory.createInterfaceDeclaration(
956+
undefined,
957+
[
958+
factory.createModifier(ts.SyntaxKind.ExportKeyword),
959+
factory.createModifier(ts.SyntaxKind.DefaultKeyword),
960+
],
961+
classInfo.name,
962+
undefined,
963+
undefined,
964+
// @ts-ignore: below TS 4.8 there were more params
965+
methods,
966+
);
967+
} else {
968+
myInterface = factory.createInterfaceDeclaration(
969+
undefined,
970+
[], // no export needed for module augmentation when class is a named export in the original file!
971+
classInfo.name,
972+
undefined,
973+
undefined,
974+
// @ts-ignore: below TS 4.8 there were more params
975+
methods,
976+
);
977+
}
908978
}
909979
addLineBreakBefore(myInterface, 2);
910980

@@ -945,8 +1015,9 @@ function buildAST(
9451015
statements.push(genericEventDefinitionModule);
9461016
}
9471017

948-
// if needed, assemble the second module declaration
949-
if (requiredImports.selfIsUsed) {
1018+
// If needed, assemble the second module declaration.
1019+
// 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
1020+
if (requiredImports.selfIsUsed && isDefaultExport) {
9501021
let myInterface2;
9511022
if (parseFloat(ts.version) >= 4.8) {
9521023
myInterface2 = factory.createInterfaceDeclaration(
@@ -988,7 +1059,7 @@ function buildAST(
9881059
ts.addSyntheticLeadingComment(
9891060
module2,
9901061
ts.SyntaxKind.SingleLineCommentTrivia,
991-
" this duplicate interface without export is needed to avoid \"Cannot find name '" +
1062+
" 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
9921063
classInfo.name +
9931064
"'\" TypeScript errors above",
9941065
);

0 commit comments

Comments
 (0)