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/**/*"] +}