diff --git a/packages/ts-interface-generator/README.md b/packages/ts-interface-generator/README.md
index 84229837..d8edaaa8 100644
--- a/packages/ts-interface-generator/README.md
+++ b/packages/ts-interface-generator/README.md
@@ -97,18 +97,78 @@ This is a problem for application code using controls developed in TypeScript as
This tool scans all TypeScript source files for top-level definitions of classes 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).
-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.
+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 TypeScripts [Declaration Merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) concept.
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.
Oh, and the tool itself is implemented in TypeScript because TypeScript makes development more efficient. ;-)
+## Features
+
+### Handling `default` and named exports
+
+In order that the Declaration Merging of TypeScript works, the modifiers of the generated interface has to match the parsed UI5 artifact.
+
+- **Named export:**
+
+ ```typescript
+ export class MyCustomControl extends Control {
+ ...
+ }
+ ```
+
+ becomes
+
+ ```typescript
+ export interface MyCustomControl {
+ ...
+ }
+ ```
+
+- **Default export:**
+
+ ```typescript
+ export default abstract class MyAbstractControl extends Control {
+ ...
+ }
+ ```
+
+ becomes
+
+ ```typescript
+ export default interface MyAbstractControl {
+ ...
+ }
+ ```
+
+### Generics
+
+This tool also supports generic classes which extending `ManagedObject` class. In order to enable the Declaration Merging of TypeScript the generic type parameters needs to be incooperated into the generated interface declaration.
+It takes care that types used to constrain the generic type parameter are imported in the generated interface if required.
+
+```typescript
+export interface ICommonListOptions {
+ mode: 'ul' | 'ol';
+}
+
+export default abstract class MyList {
+ ...
+}
+```
+
+becomes
+
+```typescript
+export default interface MyList {
+ ...
+}
+```
+
## TODO
- copy the original API documentation to the generated methods
- make sure watch mode does it right (also run on deletion? Delete interfaces before-creating? Only create interfaces for updated files?)
- consider further information like deprecation etc.
-- 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.
- ...
## Support
diff --git a/packages/ts-interface-generator/src/astGenerationHelper.ts b/packages/ts-interface-generator/src/astGenerationHelper.ts
index 2e643e6f..d2a94840 100644
--- a/packages/ts-interface-generator/src/astGenerationHelper.ts
+++ b/packages/ts-interface-generator/src/astGenerationHelper.ts
@@ -268,6 +268,126 @@ function createBindingStringTypeNode() {
);
}
+function generateGenericTypeImports(
+ sourceFile: ts.SourceFile,
+ classDeclaration: ts.ClassDeclaration,
+ statements: ts.Statement[],
+ requiredImports: RequiredImports
+): ts.Statement[] {
+ const { typeParameters } = classDeclaration;
+ const requiredGenericTypeImports: ts.Statement[] = [];
+
+ if (!typeParameters || typeParameters?.length === 0) {
+ return requiredGenericTypeImports;
+ }
+
+ const existingImportsInSourceFile: {
+ [name: string]: { statement: ts.ImportDeclaration; exportName?: string };
+ } = {};
+
+ for (const statement of sourceFile.statements) {
+ if (ts.isImportDeclaration(statement)) {
+ if (statement.importClause) {
+ const { name, namedBindings } = statement.importClause;
+
+ if (name) {
+ existingImportsInSourceFile[name.getText()] = { statement };
+ }
+
+ if (namedBindings) {
+ namedBindings.forEachChild((node) => {
+ if (ts.isImportSpecifier(node)) {
+ const typeName = node.name.getText();
+ let exportName = typeName;
+
+ if (node.propertyName) {
+ exportName = node.propertyName.getText();
+ }
+
+ existingImportsInSourceFile[typeName] = { statement, exportName };
+ }
+ });
+ }
+ }
+ }
+ }
+
+ for (const typeParameter of typeParameters) {
+ if (typeParameter.constraint) {
+ const typeName = typeParameter.constraint.getText();
+
+ if (nameIsUsed(typeName, requiredImports)) {
+ // import is already created
+ continue;
+ } else if (
+ Object.prototype.hasOwnProperty.call(
+ existingImportsInSourceFile,
+ typeName
+ )
+ ) {
+ const { statement, exportName } = existingImportsInSourceFile[typeName];
+
+ const moduleName = statement.moduleSpecifier.getText();
+ const moduleSpecifierClone = factory.createStringLiteral(
+ moduleName.substring(1, moduleName.length - 1)
+ );
+
+ let importClause: ts.ImportClause;
+ const typeNameIdentifier = factory.createIdentifier(typeName);
+
+ if (!exportName) {
+ importClause = factory.createImportClause(
+ true,
+ typeNameIdentifier,
+ undefined
+ );
+ } else {
+ const propertyName =
+ typeName !== exportName
+ ? factory.createIdentifier(exportName)
+ : undefined;
+
+ let importSpecifier: ts.ImportSpecifier;
+
+ // TODO: Use a method to check for versions
+ if (parseFloat(ts.version) >= 4.5) {
+ // @ts-ignore after 4.5, createImportSpecifier got a third parameter (in the beginning!). This code shall work with older and newer versions, but as the compile-time error check is considering either <4.5 or >=4.5, one of these lines is recognized as error
+ importSpecifier = factory.createImportSpecifier(
+ true,
+ propertyName,
+ typeNameIdentifier
+ );
+ } else {
+ // @ts-ignore after 4.5, createImportSpecifier got a third parameter (in the beginning!). This code shall work with older and newer versions, but as the compile-time error check is considering either <4.5 or >=4.5, one of these lines is recognized as error
+ importSpecifier = factory.createImportSpecifier(
+ propertyName,
+ typeNameIdentifier
+ );
+ }
+
+ importClause = factory.createImportClause(
+ false,
+ undefined,
+ factory.createNamedImports([importSpecifier])
+ );
+ }
+
+ const clone = factory.createImportDeclaration(
+ statement.decorators,
+ statement.modifiers,
+ importClause,
+ moduleSpecifierClone,
+ statement.assertClause
+ );
+
+ requiredGenericTypeImports.push(clone);
+ }
+ }
+ }
+
+ return requiredGenericTypeImports;
+}
+
function printConstructorBlockWarning(
settingsTypeName: string,
className: string,
@@ -1231,6 +1351,7 @@ function createConstructorBlock(settingsTypeName: string) {
export {
generateMethods,
generateSettingsInterface,
+ generateGenericTypeImports,
addLineBreakBefore,
createConstructorBlock,
};
diff --git a/packages/ts-interface-generator/src/interfaceGenerationHelper.ts b/packages/ts-interface-generator/src/interfaceGenerationHelper.ts
index 4613143c..249fd5a8 100644
--- a/packages/ts-interface-generator/src/interfaceGenerationHelper.ts
+++ b/packages/ts-interface-generator/src/interfaceGenerationHelper.ts
@@ -7,6 +7,7 @@ import {
generateMethods,
generateSettingsInterface,
addLineBreakBefore,
+ generateGenericTypeImports,
} from "./astGenerationHelper";
import astToString from "./astToString";
import log from "loglevel";
@@ -34,6 +35,8 @@ const interestingBaseSettingsClasses: {
'"sap/ui/core/Control".$ControlSettings': "$ControlSettings",
};
+const interfaceIncompatibleModifiers = new Set([ts.SyntaxKind.AbstractKeyword]);
+
/**
* Checks the given source file for any classes derived from sap.ui.base.ManagedObject and generates for each one an interface file next to the source file
* with the name .gen.d.ts
@@ -429,6 +432,7 @@ function generateInterface(
{
sourceFile,
className,
+ classDeclaration,
settingsTypeFullName,
interestingBaseClass,
constructorSignaturesAvailable,
@@ -436,6 +440,7 @@ function generateInterface(
}: {
sourceFile: ts.SourceFile;
className: string;
+ classDeclaration: ts.ClassDeclaration;
settingsTypeFullName: string;
interestingBaseClass: "ManagedObject" | "Element" | "Control" | undefined;
constructorSignaturesAvailable: boolean;
@@ -478,7 +483,8 @@ function generateInterface(
const moduleName = path.basename(fileName, path.extname(fileName));
const ast = buildAST(
classInfo,
- sourceFile.fileName,
+ sourceFile,
+ classDeclaration,
constructorSignaturesAvailable,
moduleName,
settingsTypeFullName,
@@ -494,12 +500,15 @@ function generateInterface(
function buildAST(
classInfo: ClassInfo,
- classFileName: string,
+ sourceFile: ts.SourceFile,
+ classDeclaration: ts.ClassDeclaration,
constructorSignaturesAvailable: boolean,
moduleName: string,
settingsTypeFullName: string,
allKnownGlobals: GlobalToModuleMapping
) {
+ const { fileName: classFileName } = sourceFile;
+
const requiredImports: RequiredImports = {};
const methods = generateMethods(classInfo, requiredImports, allKnownGlobals);
if (methods.length === 0) {
@@ -518,14 +527,24 @@ function buildAST(
const statements: ts.Statement[] = getImports(requiredImports);
+ const requiredGenericTypeImports = generateGenericTypeImports(
+ sourceFile,
+ classDeclaration,
+ statements,
+ requiredImports
+ );
+
+ if (requiredGenericTypeImports.length > 0) {
+ statements.push(...requiredGenericTypeImports);
+ }
+
const myInterface = factory.createInterfaceDeclaration(
undefined,
- [
- factory.createModifier(ts.SyntaxKind.ExportKeyword),
- factory.createModifier(ts.SyntaxKind.DefaultKeyword),
- ],
+ classDeclaration.modifiers?.filter(
+ (modifier) => !interfaceIncompatibleModifiers.has(modifier.kind)
+ ),
classInfo.name,
- undefined,
+ classDeclaration.typeParameters,
undefined,
methods
);
diff --git a/packages/ts-interface-generator/src/test/generateInterfaceWithGenerics.test.ts b/packages/ts-interface-generator/src/test/generateInterfaceWithGenerics.test.ts
new file mode 100644
index 00000000..0479df91
--- /dev/null
+++ b/packages/ts-interface-generator/src/test/generateInterfaceWithGenerics.test.ts
@@ -0,0 +1,72 @@
+import ts from "typescript";
+import { generateInterfaces } from "../interfaceGenerationHelper";
+import { initialize } from "../typeScriptEnvironment";
+
+test("Generating the interface for a class using generics", () => {
+ const expected = `import { PropertyBindingInfo } from "sap/ui/base/ManagedObject";
+import { $ManagedObjectSettings } from "sap/ui/base/ManagedObject";
+
+declare module "./SampleGenericManagedObject" {
+
+ /**
+ * Interface defining the settings object used in constructor calls
+ */
+ interface $SampleGenericManagedObjectSettings extends $ManagedObjectSettings {
+ text?: string | PropertyBindingInfo;
+ }
+
+ export default interface SampleGenericManagedObject {
+
+ // property: text
+ getText(): string;
+ setText(text: string): this;
+ }
+}
+`;
+
+ function onTSProgramUpdate(
+ program: ts.Program,
+ typeChecker: ts.TypeChecker,
+ changedFiles: string[], // is an empty array in non-watch case; is at least one file in watch case
+ allKnownGlobals: {
+ [key: string]: { moduleName: string; exportName?: string };
+ }
+ ) {
+ // files recognized as "real" app source files should be exactly one: SampleGenericManagedObject.ts
+ const sourceFiles: ts.SourceFile[] = program
+ .getSourceFiles()
+ .filter((sourceFile) => {
+ if (
+ sourceFile.fileName.indexOf("@types") === -1 &&
+ sourceFile.fileName.indexOf("node_modules/") === -1 &&
+ sourceFile.fileName.indexOf(".gen.d.ts") === -1
+ ) {
+ return true;
+ }
+ });
+ expect(sourceFiles).toHaveLength(1);
+ expect(sourceFiles[0].fileName).toMatch(/.*SampleGenericManagedObject.ts/);
+
+ // this function will be called with the resulting generated interface text - here the big result check occurs
+ function checkResult(
+ sourceFileName: string,
+ className: string,
+ interfaceText: string
+ ) {
+ expect(sourceFileName).toMatch(/.*SampleGenericManagedObject.ts/);
+ expect(className).toEqual("SampleGenericManagedObject");
+
+ expect(interfaceText).toEqual(expected);
+ }
+
+ // trigger the interface generation - the result will be given to and checked in the function above
+ generateInterfaces(
+ sourceFiles[0],
+ typeChecker,
+ allKnownGlobals,
+ checkResult
+ );
+ }
+
+ initialize("./tsconfig-testgenerics.json", onTSProgramUpdate, {});
+});
diff --git a/packages/ts-interface-generator/src/test/generateInterfaceWithTypedGenerics.test.ts b/packages/ts-interface-generator/src/test/generateInterfaceWithTypedGenerics.test.ts
new file mode 100644
index 00000000..a5abc777
--- /dev/null
+++ b/packages/ts-interface-generator/src/test/generateInterfaceWithTypedGenerics.test.ts
@@ -0,0 +1,75 @@
+import ts from "typescript";
+import { generateInterfaces } from "../interfaceGenerationHelper";
+import { initialize } from "../typeScriptEnvironment";
+
+test("Generating the interface for a class using typed generics", () => {
+ const expected = `import { PropertyBindingInfo } from "sap/ui/base/ManagedObject";
+import { $ManagedObjectSettings } from "sap/ui/base/ManagedObject";
+import { type $UIComponentSettings } from "sap/ui/core/UIComponent";
+import { type $ComponentSettings as $RenamedComponentSettings } from "sap/ui/core/Component";
+import type Component from "sap/ui/core/Component";
+
+declare module "./SampleGenericManagedObject" {
+
+ /**
+ * Interface defining the settings object used in constructor calls
+ */
+ interface $SampleGenericManagedObjectSettings extends $ManagedObjectSettings {
+ text?: string | PropertyBindingInfo;
+ }
+
+ export default interface SampleGenericManagedObject {
+
+ // property: text
+ getText(): string;
+ setText(text: string): this;
+ }
+}
+`;
+
+ function onTSProgramUpdate(
+ program: ts.Program,
+ typeChecker: ts.TypeChecker,
+ changedFiles: string[], // is an empty array in non-watch case; is at least one file in watch case
+ allKnownGlobals: {
+ [key: string]: { moduleName: string; exportName?: string };
+ }
+ ) {
+ // files recognized as "real" app source files should be exactly one: SampleGenericManagedObject.ts
+ const sourceFiles: ts.SourceFile[] = program
+ .getSourceFiles()
+ .filter((sourceFile) => {
+ if (
+ sourceFile.fileName.indexOf("@types") === -1 &&
+ sourceFile.fileName.indexOf("node_modules/") === -1 &&
+ sourceFile.fileName.indexOf(".gen.d.ts") === -1
+ ) {
+ return true;
+ }
+ });
+ expect(sourceFiles).toHaveLength(1);
+ expect(sourceFiles[0].fileName).toMatch(/.*SampleGenericManagedObject.ts/);
+
+ // this function will be called with the resulting generated interface text - here the big result check occurs
+ function checkResult(
+ sourceFileName: string,
+ className: string,
+ interfaceText: string
+ ) {
+ expect(sourceFileName).toMatch(/.*SampleGenericManagedObject.ts/);
+ expect(className).toEqual("SampleGenericManagedObject");
+
+ expect(interfaceText).toEqual(expected);
+ }
+
+ // trigger the interface generation - the result will be given to and checked in the function above
+ generateInterfaces(
+ sourceFiles[0],
+ typeChecker,
+ allKnownGlobals,
+ checkResult
+ );
+ }
+
+ initialize("./tsconfig-testgenerics-typed.json", onTSProgramUpdate, {});
+});
diff --git a/packages/ts-interface-generator/src/test/testdata/generics/simple/SampleGenericManagedObject.ts b/packages/ts-interface-generator/src/test/testdata/generics/simple/SampleGenericManagedObject.ts
new file mode 100644
index 00000000..9354a8e1
--- /dev/null
+++ b/packages/ts-interface-generator/src/test/testdata/generics/simple/SampleGenericManagedObject.ts
@@ -0,0 +1,20 @@
+import ManagedObject from "sap/ui/base/ManagedObject";
+
+/**
+ * @name ui5tssampleapp.generics.SampleGenericManagedObject
+ */
+export default class SampleGenericManagedObject<
+ TOptions,
+ TOptions2
+> extends ManagedObject {
+ static readonly metadata = {
+ properties: {
+ text: {
+ type: "string",
+ },
+ },
+ };
+
+ private _options: TOptions;
+ private _options2: TOptions2;
+}
diff --git a/packages/ts-interface-generator/src/test/testdata/generics/typed_condition/SampleGenericManagedObject.ts b/packages/ts-interface-generator/src/test/testdata/generics/typed_condition/SampleGenericManagedObject.ts
new file mode 100644
index 00000000..8d077bec
--- /dev/null
+++ b/packages/ts-interface-generator/src/test/testdata/generics/typed_condition/SampleGenericManagedObject.ts
@@ -0,0 +1,30 @@
+/* eslint-disable @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any */
+
+import ManagedObject from "sap/ui/base/ManagedObject";
+import { $UIComponentSettings } from "sap/ui/core/UIComponent";
+import Component, {
+ $ComponentSettings as $RenamedComponentSettings,
+} from "sap/ui/core/Component";
+
+/**
+ * @name ui5tssampleapp.generics.SampleGenericManagedObject
+ */
+export default class SampleGenericManagedObject<
+ TOptions extends $UIComponentSettings,
+ TOptions2 extends $RenamedComponentSettings,
+ TOptions3 extends Component,
+ TOptions4 extends {} = any
+> extends ManagedObject {
+ static readonly metadata = {
+ properties: {
+ text: {
+ type: "string",
+ },
+ },
+ };
+
+ private _uiComponentSettings: TOptions;
+ private _componentSettings: TOptions2;
+ private _component: TOptions3;
+ private _any: TOptions4;
+}
diff --git a/packages/ts-interface-generator/tsconfig-testgenerics-typed.json b/packages/ts-interface-generator/tsconfig-testgenerics-typed.json
new file mode 100644
index 00000000..b07d0a6d
--- /dev/null
+++ b/packages/ts-interface-generator/tsconfig-testgenerics-typed.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "rootDirs": ["./src/test/testdata/generics"],
+ "outDir": "./src/test/dist",
+ "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
+ "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
+ "sourceMap": true /* Generates corresponding '.map' file. */,
+ "strict": true /* Enable all strict type-checking options. */,
+ "strictNullChecks": false /* Enable strict null checks. */,
+ "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
+ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
+ "skipLibCheck": true /* Skip type checking of declaration files. */,
+ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
+ "typeRoots": [
+ "./node_modules/@types",
+ "../../node_modules/@types",
+ "./node_modules/@sapui5/ts-types-esm",
+ "../../node_modules/@sapui5/ts-types-esm"
+ ]
+ },
+ "include": ["./src/test/testdata/generics/typed_condition/**/*"]
+}
diff --git a/packages/ts-interface-generator/tsconfig-testgenerics.json b/packages/ts-interface-generator/tsconfig-testgenerics.json
new file mode 100644
index 00000000..b9b75e7a
--- /dev/null
+++ b/packages/ts-interface-generator/tsconfig-testgenerics.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "rootDirs": ["./src/test/testdata/generics"],
+ "outDir": "./src/test/dist",
+ "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
+ "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
+ "sourceMap": true /* Generates corresponding '.map' file. */,
+ "strict": true /* Enable all strict type-checking options. */,
+ "strictNullChecks": false /* Enable strict null checks. */,
+ "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
+ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
+ "skipLibCheck": true /* Skip type checking of declaration files. */,
+ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
+ "typeRoots": [
+ "./node_modules/@types",
+ "../../node_modules/@types",
+ "./node_modules/@sapui5/ts-types-esm",
+ "../../node_modules/@sapui5/ts-types-esm"
+ ]
+ },
+ "include": ["./src/test/testdata/generics/simple/**/*"]
+}