Skip to content

Commit 3f58470

Browse files
RandomBytematz3
authored andcommitted
Implement TypeScript compiler based AMD transpiler (#55)
Adds sap.ui.require support (async and probing) JIRA: CPOUI5FOUNDATION-804 --------- Co-authored-by: Matthias Osswald <[email protected]>
1 parent f9b7747 commit 3f58470

File tree

66 files changed

+5266
-262
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+5266
-262
lines changed
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import ts from "typescript";
2+
import {getLogger} from "@ui5/logger";
3+
import parseModuleDeclaration from "./parseModuleDeclaration.js";
4+
import moduleDeclarationToDefinition, {ModuleDefinition} from "./moduleDeclarationToDefinition.js";
5+
import parseRequire from "./parseRequire.js";
6+
import {transformAsyncRequireCall, transformSyncRequireCall} from "./requireExpressionToTransformation.js";
7+
import pruneNode, {UnsafeNodeRemoval} from "./pruneNode.js";
8+
import replaceNodeInParent, {NodeReplacement} from "./replaceNodeInParent.js";
9+
import {UnsupportedModuleError} from "./util.js";
10+
11+
const log = getLogger("transpilers:amd:TsTransformer");
12+
13+
// Augment typescript's Node interface to add a property for marking nodes for removal
14+
declare module "typescript" {
15+
interface Node {
16+
_remove?: boolean
17+
}
18+
}
19+
20+
/**
21+
* Creates a TypeScript "transformer" that will be applied to each source file, doing the actual transpilation
22+
* The source file is expected to be classic UI5 JavaScript, using UI5s's AMD loader and other UI5 specific API.
23+
* Modern JavaScript language features are generally supported, however there might be gaps in the implementation
24+
* at the time of writing this comment.
25+
*
26+
* If a transformation of a specific API (such as "sap.ui.define") is not possible for some reason, an "Unsupported*"
27+
* error is thrown. In that case, the rest of the module is still processed. However it's possible that the result
28+
* will be equal to the input.
29+
*/
30+
export function createTransformer(resourcePath: string, program: ts.Program): ts.TransformerFactory<ts.SourceFile> {
31+
return function transformer(context: ts.TransformationContext) {
32+
return (sourceFile: ts.SourceFile): ts.SourceFile => {
33+
return transform(resourcePath, program, sourceFile, context);
34+
};
35+
};
36+
}
37+
38+
function transform(
39+
resourcePath: string, program: ts.Program, sourceFile: ts.SourceFile, context: ts.TransformationContext
40+
): ts.SourceFile {
41+
const checker = program.getTypeChecker();
42+
const {factory: nodeFactory} = context;
43+
const moduleDefinitions: ModuleDefinition[] = [];
44+
// TODO: Filter duplicate imports, maybe group by module definition
45+
const requireImports: ts.ImportDeclaration[] = [];
46+
const requireFunctions: ts.FunctionDeclaration[] = [];
47+
const nodeReplacements = new Map<ts.Node, NodeReplacement[]>();
48+
49+
function replaceNode(node: ts.Node, substitute: ts.Node) {
50+
let replacements = nodeReplacements.get(node.parent);
51+
if (!replacements) {
52+
replacements = [];
53+
nodeReplacements.set(node.parent, replacements);
54+
}
55+
replacements.push({original: node, substitute});
56+
}
57+
58+
// Visit the AST depth-first and collect module definitions
59+
function visit(nodeIn: ts.Node): ts.VisitResult<ts.Node> {
60+
const node = ts.visitEachChild(nodeIn, visit, context);
61+
if (ts.isCallExpression(node) &&
62+
ts.isPropertyAccessExpression(node.expression)) {
63+
if (matchPropertyAccessExpression(node.expression, "sap.ui.define")) {
64+
try {
65+
const moduleDeclaration = parseModuleDeclaration(node.arguments, checker);
66+
const moduleDefinition = moduleDeclarationToDefinition(moduleDeclaration, sourceFile, nodeFactory);
67+
moduleDefinitions.push(moduleDefinition);
68+
pruneNode(node); // Mark the define call for removal
69+
} catch(err) {
70+
if (err instanceof UnsupportedModuleError) {
71+
log.verbose(`Failed to transform sap.ui.define call in ${resourcePath}: ${err.message}`);
72+
} else {
73+
throw err;
74+
}
75+
}
76+
} else if (matchPropertyAccessExpression(node.expression, "sap.ui.require")) {
77+
try {
78+
const requireExpression = parseRequire(node.arguments, checker);
79+
if (requireExpression.async) {
80+
const res = transformAsyncRequireCall(node, requireExpression, nodeFactory);
81+
requireImports.push(...res.imports);
82+
if (res.callback) {
83+
replaceNode(node, res.callback);
84+
if (res.errback) {
85+
requireFunctions.push(res.errback);
86+
}
87+
} else {
88+
// async sap.ui.require without a callback (import only)
89+
try {
90+
pruneNode(node);
91+
} catch(err) {
92+
if (err instanceof UnsafeNodeRemoval) {
93+
// If removal is not possible, replace the CallExpression with "undefined"
94+
// (i.e. the original return value)
95+
replaceNode(node, nodeFactory.createIdentifier("undefined"));
96+
} else {
97+
throw err;
98+
}
99+
}
100+
}
101+
} else {
102+
const res = transformSyncRequireCall(node, requireExpression, nodeFactory);
103+
requireImports.push(res.import);
104+
replaceNode(node, res.requireStatement);
105+
}
106+
} catch(err) {
107+
if (err instanceof UnsupportedModuleError) {
108+
log.verbose(`Failed to transform sap.ui.require call in ${resourcePath}: ${err.message}`);
109+
} else {
110+
throw err;
111+
}
112+
}
113+
}
114+
}
115+
return node;
116+
}
117+
let processedSourceFile = ts.visitNode(sourceFile, visit) as ts.SourceFile;
118+
119+
const statements = [
120+
...requireImports,
121+
...processedSourceFile.statements as unknown as ts.Statement[],
122+
...requireFunctions,
123+
];
124+
moduleDefinitions.forEach(({imports, body}) => {
125+
// Add imports of each module definition to the top of the program
126+
statements.unshift(...imports);
127+
// Add the module definition body to the end of the program
128+
statements.push(...body);
129+
});
130+
131+
// Update the AST with extracted nodes from the module definitions and require expressions
132+
processedSourceFile = nodeFactory.updateSourceFile(processedSourceFile, statements);
133+
134+
// Visit the AST breadth-first and remove nodes marked for remove as well as
135+
// replacing nodes marked for replacement
136+
function removeAndReplaceNodes(node: ts.Node): ts.VisitResult<ts.Node | undefined> {
137+
if (node._remove) {
138+
// console.log(`Cleanup: Removing node ${ts.SyntaxKind[node.kind]}`);
139+
return undefined;
140+
}
141+
const replacements = nodeReplacements.get(node);
142+
if (replacements) {
143+
for (const replacement of replacements) {
144+
node = replaceNodeInParent(node, replacement, nodeFactory);
145+
}
146+
}
147+
return ts.visitEachChild(node, removeAndReplaceNodes, context);
148+
}
149+
processedSourceFile = ts.visitNode(processedSourceFile, removeAndReplaceNodes) as ts.SourceFile;
150+
return processedSourceFile;
151+
}
152+
153+
// TODO PERF: Use a match array instead of string to be able to match individual parts of the property access chain
154+
// early (i.e. exist immediately if the last expression does not match the last match)
155+
function matchPropertyAccessExpression(node: ts.PropertyAccessExpression, match: string) : boolean {
156+
const propAccessChain: string[] = [];
157+
propAccessChain.push(node.expression.getText());
158+
159+
let scanNode: ts.Node = node;
160+
while (ts.isPropertyAccessExpression(scanNode)) {
161+
propAccessChain.push(scanNode.name.getText());
162+
scanNode = scanNode.parent;
163+
}
164+
return propAccessChain.join(".") === match;
165+
}

0 commit comments

Comments
 (0)