Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add type support for "byId" in controllers #423

Merged
merged 9 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/linter/ui5Types/TypeLinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import path from "node:path/posix";
import {AbstractAdapter} from "@ui5/fs";
import {createAdapter, createResource} from "@ui5/fs/resourceFactory";
import {loadApiExtract} from "../../utils/ApiExtract.js";
import {CONTROLLER_BY_ID_DTS_PATH} from "../xmlTemplate/linter.js";

const log = getLogger("linter:ui5Types:TypeLinter");

Expand Down Expand Up @@ -151,29 +152,38 @@ export default class TypeChecker {
resourcePath, fileContent, sourceMap);
}
}
// Although not being a typical transformed source, write out the byId dts file for debugging purposes
let byIdDts = files.get(CONTROLLER_BY_ID_DTS_PATH);
if (byIdDts) {
if (typeof byIdDts === "function") {
byIdDts = byIdDts();
}
await writeTransformedSources(process.env.UI5LINT_WRITE_TRANSFORMED_SOURCES,
CONTROLLER_BY_ID_DTS_PATH, byIdDts);
}
}
}
}

async function writeTransformedSources(fsBasePath: string,
originalResourcePath: string,
source: string, map: string | undefined) {
source: string, map?: string) {
const transformedWriter = createAdapter({
fsBasePath,
virBasePath: "/",
});

await transformedWriter.write(
createResource({
path: originalResourcePath + ".ui5lint.transformed.js",
path: originalResourcePath,
string: source,
})
);

if (map) {
await transformedWriter.write(
createResource({
path: originalResourcePath + ".ui5lint.transformed.js.map",
path: originalResourcePath + ".map",
string: JSON.stringify(JSON.parse(map), null, "\t"),
})
);
Expand Down
38 changes: 38 additions & 0 deletions src/linter/xmlTemplate/ControllerByIdInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Multiple views/fragments can use the same ID and link to the same controller,
// so we need to store a set of module names for each ID.
export type IdModulesMap = Map<string, Set<string>>;

type ControllerElementsMap = Map<string, IdModulesMap>;

export default class ControllerByIdInfo {
private map: ControllerElementsMap = new Map();

private getControllerMapping(controllerName: string) {
let controllerMap = this.map.get(controllerName);
if (!controllerMap) {
controllerMap = new Map();
this.map.set(controllerName, controllerMap);
}
return controllerMap;
}

public addMappings(controllerName: string, idModuleMap: Map<string, string>) {
// Do not add empty mappings
if (!idModuleMap.size) {
return;
}
const controllerMapping = this.getControllerMapping(controllerName);
for (const [id, module] of idModuleMap) {
let existingModules = controllerMapping.get(id);
if (!existingModules) {
existingModules = new Set();
controllerMapping.set(id, existingModules);
}
existingModules.add(module);
}
}

public getMappings() {
return this.map;
}
}
9 changes: 6 additions & 3 deletions src/linter/xmlTemplate/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import AbstractGenerator from "./generator/AbstractGenerator.js";
import {getLogger} from "@ui5/logger";
import {MESSAGE} from "../messages.js";
import {ApiExtract} from "../../utils/ApiExtract.js";
import ControllerByIdInfo from "./ControllerByIdInfo.js";
const log = getLogger("linter:xmlTemplate:Parser");

export type Namespace = string;
Expand Down Expand Up @@ -133,16 +134,18 @@ export default class Parser {
#generator: AbstractGenerator;
#apiExtract: ApiExtract;

constructor(resourceName: string, apiExtract: ApiExtract, context: LinterContext) {
constructor(
resourceName: string, apiExtract: ApiExtract, context: LinterContext, controllerByIdInfo: ControllerByIdInfo
) {
const xmlDocumentKind = determineDocumentKind(resourceName);
if (xmlDocumentKind === null) {
throw new Error(`Unknown document type for resource ${resourceName}`);
}
this.#resourceName = resourceName;
this.#xmlDocumentKind = xmlDocumentKind;
this.#generator = xmlDocumentKind === DocumentKind.View ?
new ViewGenerator(resourceName) :
new FragmentGenerator(resourceName);
new ViewGenerator(resourceName, controllerByIdInfo) :
new FragmentGenerator(resourceName, controllerByIdInfo);

this.#apiExtract = apiExtract;
this.#context = context;
Expand Down
17 changes: 15 additions & 2 deletions src/linter/xmlTemplate/generator/AbstractGenerator.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ControllerByIdInfo from "../ControllerByIdInfo.js";
import {
ControlDeclaration, RequireExpression, Position,
} from "../Parser.js";
Expand All @@ -17,10 +18,13 @@ export default abstract class AbstractGenerator {
_imports = new Set<ImportStatement>();
_variableNames = new Set<string>();
_body: Writer;
_idToModule = new Map<string, string>();
_controllerByIdInfo: ControllerByIdInfo;

constructor(filePath: string) {
constructor(filePath: string, controllerByIdInfo: ControllerByIdInfo) {
const fileName = path.basename(filePath, ".xml");
this._body = new Writer(fileName + ".js", fileName + ".xml");
this._controllerByIdInfo = controllerByIdInfo;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -31,9 +35,11 @@ export default abstract class AbstractGenerator {
writeControl(controlDeclaration: ControlDeclaration) {
const importVariableName = this._getUniqueVariableName(controlDeclaration.name);

const moduleName = controlDeclaration.namespace.replaceAll(".", "/") + `/${controlDeclaration.name}`;

// Add import
this._imports.add({
moduleName: controlDeclaration.namespace.replaceAll(".", "/") + `/${controlDeclaration.name}`,
moduleName,
variableName: importVariableName,
start: controlDeclaration.start,
end: controlDeclaration.end,
Expand All @@ -46,6 +52,13 @@ export default abstract class AbstractGenerator {
this._body.writeln(`new ${importVariableName}({`, controlDeclaration.start, controlDeclaration.end);
// Write properties
controlDeclaration.properties.forEach((attribute) => {
// Add mapping of id to module name so that specific byId lookup for controllers can be generated.
// This information can only be added to the ControllerByIdInfo once the controllerName is known,
// which happens last as outer nodes are visited last.
if (attribute.name === "id") {
this._idToModule.set(attribute.value, moduleName);
}

// TODO: Determine attribute type based on metadata and parse/write value accordingly
this._body.write(` `);
this._body.writeln(
Expand Down
70 changes: 70 additions & 0 deletions src/linter/xmlTemplate/generator/ControllerByIdDtsGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import ControllerByIdInfo, {IdModulesMap} from "../ControllerByIdInfo.js";

export class ControllerByIdDtsGenerator {
// Maps module names to local names
private imports = new Map<string, string>();

generate(controllerByIdInfo: ControllerByIdInfo) {
const mappings = controllerByIdInfo.getMappings();
if (!mappings.size) {
return null;
}
this.imports = new Map<string, string>();
this.addImport("sap/ui/core/mvc/View"); // View is needed for interface ControllerView
this.addImport("sap/ui/core/Element"); // Element is needed for byId fallback signature
let out = "";
mappings.forEach((idToModules, controllerName) => {
out += this.generateModuleDeclaration(controllerName, idToModules);
});
return this.generateCollectedImports() + out;
}

private generateCollectedImports() {
return Array.from(this.imports.entries())
.map(([moduleName, localName]) => `import ${localName} from "${moduleName}";`)
.join("\n") + "\n";
}

private generateByIdMapping(idToModules: IdModulesMap) {
let out = "interface ByIdMapping {\n";
idToModules.forEach((modules, id) => {
const localNames = Array.from(modules).map((moduleName: string) => this.addImport(moduleName));
out += `\t\t"${id}": ${localNames.join(" | ")};\n`;
});
out += "\t}";
return out;
}

private generateModuleDeclaration(controllerName: string, idToModules: IdModulesMap) {
const moduleName = controllerName.replace(/\./g, "/") + ".controller";

return `
declare module "${moduleName}" {
${this.generateByIdMapping(idToModules)}
type ByIdFunction = {
<T extends keyof ByIdMapping>(sId: T): ByIdMapping[T];
(sId: string): ${this.imports.get("sap/ui/core/Element")};
}
interface ControllerView extends ${this.imports.get("sap/ui/core/mvc/View")} {
byId: ByIdFunction;
}
export default interface Controller {
byId: ByIdFunction;
getView(): ControllerView;
}
}
`;
}

private addImport(moduleName: string) {
const localName = this.getLocalModuleName(moduleName);
if (!this.imports.has(moduleName)) {
this.imports.set(moduleName, localName);
}
return localName;
}

private getLocalModuleName(moduleName: string) {
return moduleName.replace(/[/.]/g, "_");
}
}
7 changes: 7 additions & 0 deletions src/linter/xmlTemplate/generator/ViewGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import {ControlDeclaration} from "../Parser.js";

export default class ViewGenerator extends AbstractGenerator {
writeRootControl(controlInfo: ControlDeclaration) {
controlInfo.properties.forEach((attribute) => {
if (attribute.name === "controllerName") {
// Outer nodes are visited last, so the controllerName is only known after
// all controls have been visited. Only then the collected mappings can be added.
this._controllerByIdInfo.addMappings(attribute.value, this._idToModule);
}
});
this._body.write(`export default `);
this.writeControl(controlInfo);
}
Expand Down
20 changes: 19 additions & 1 deletion src/linter/xmlTemplate/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ import {Resource} from "@ui5/fs";
import {createResource} from "@ui5/fs/resourceFactory";
import transpileXml from "./transpiler.js";
import {LinterParameters} from "../LinterContext.js";
import ControllerByIdInfo from "./ControllerByIdInfo.js";
import {ControllerByIdDtsGenerator} from "./generator/ControllerByIdDtsGenerator.js";

// For usage in TypeLinter to write the file as part of the internal WRITE_TRANSFORMED_SOURCES debug mode
export const CONTROLLER_BY_ID_DTS_PATH = "/types/@ui5/linter/virtual/ControllerById.d.ts";

export default async function lintXml({filePathsWorkspace, workspace, context}: LinterParameters) {
const xmlResources = await filePathsWorkspace.byGlob("**/{*.view.xml,*.fragment.xml}");

const controllerByIdInfo = new ControllerByIdInfo();

await Promise.all(xmlResources.map(async (resource: Resource) => {
const res = await transpileXml(resource.getPath(), resource.getStream(), context);
const res = await transpileXml(resource.getPath(), resource.getStream(), context, controllerByIdInfo);
if (!res) {
return;
}
Expand All @@ -31,4 +38,15 @@ export default async function lintXml({filePathsWorkspace, workspace, context}:
await filePathsWorkspace.write(transpiledResourceSourceMap);
await workspace.write(transpiledResourceSourceMap);
}));

// Generate dts file with specific byId signatures for controllers based on view IDs
const controllerByIdDtsGenerator = new ControllerByIdDtsGenerator();
const controllerByIdDts = controllerByIdDtsGenerator.generate(controllerByIdInfo);
if (controllerByIdDts) {
const dtsResource = createResource({
path: CONTROLLER_BY_ID_DTS_PATH,
string: controllerByIdDts,
});
await workspace.write(dtsResource);
}
}
9 changes: 5 additions & 4 deletions src/linter/xmlTemplate/transpiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {getLogger} from "@ui5/logger";
import {createRequire} from "node:module";
import {MESSAGE} from "../messages.js";
import {loadApiExtract, ApiExtract} from "../../utils/ApiExtract.js";
import ControllerByIdInfo from "./ControllerByIdInfo.js";
const require = createRequire(import.meta.url);

const log = getLogger("linter:xmlTemplate:transpiler");
Expand All @@ -17,12 +18,12 @@ let saxWasmBuffer: Buffer;
let apiExtract: ApiExtract;

export default async function transpileXml(
resourcePath: string, contentStream: ReadStream, context: LinterContext
resourcePath: string, contentStream: ReadStream, context: LinterContext, controllerByIdInfo: ControllerByIdInfo
): Promise<TranspileResult | undefined> {
await init();
try {
const taskEnd = taskStart("Transpile XML", resourcePath, true);
const res = await transpileXmlToJs(resourcePath, contentStream, context);
const res = await transpileXmlToJs(resourcePath, contentStream, context, controllerByIdInfo);
taskEnd();
if (!res.source) {
log.verbose(`XML transpiler returned no result for ${resourcePath}`);
Expand Down Expand Up @@ -56,9 +57,9 @@ async function init() {
}

async function transpileXmlToJs(
resourcePath: string, contentStream: ReadStream, context: LinterContext
resourcePath: string, contentStream: ReadStream, context: LinterContext, controllerByIdInfo: ControllerByIdInfo
): Promise<TranspileResult> {
const parser = new Parser(resourcePath, apiExtract, context);
const parser = new Parser(resourcePath, apiExtract, context, controllerByIdInfo);

// Initialize parser
const options = {highWaterMark: 32 * 1024}; // 32k chunks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ sap.ui.define(["./BaseController", "sap/m/MessageBox"], function (BaseController
return BaseController.extend("com.ui5.troublesome.app.controller.Main", {
sayHello: function () {
MessageBox.show("Hello World!");
},

registerButtonEventHandlers() {
// this.byId and this.getView().byId should report the same issues
this.byId("helloButton").attachTap(function() {
console.log("Tapped");
});
this.getView().byId("helloButton").attachTap(function() {
console.log("Tapped");
});

// testButton exists in two views and could be a sap.m.Button or a sap.ui.commons.Button.
// The detection of deprecated button API depends requires TypeScript compliant probing (e.g. using "attachTap" in testButton).
// In any case, the detection of UI5 Base Control API should still work as both inherit from it.
const testButton = this.byId("testButton");
testButton.getBlocked(); // This should be reported
if ("attachTap" in testButton) {
// When probing for the existence of the method, the type can be determined and the issue should be reported
testButton.attachTap(function() {
console.log("Tapped");
});
}

// UI5 Element API should still be checked for unknown IDs
this.byId("unknown").prop("foo", "bar");
this.getView().byId("unknown").prop("foo", "bar");
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!--
This file is a partial copy of Main.view.xml but uses other controls.
Its purpose is to cover the case where multiple views refer to
the same controller (controllerName) and contain controls with
the same ID but a different type. The resulting type for the
controller byId() call should be the union of the types.
-->
<mvc:View
controllerName="com.ui5.troublesome.app.controller.Main"
displayBlock="true"
xmlns="sap.ui.commons"
xmlns:mvc="sap.ui.core.mvc"
xmlns:core="sap.ui.core">

<Button id="testButton" />

</mvc:View>
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@
</buttons>
</MessagePage>

<Button id="testButton" />

</mvc:View>
Loading