/
projectPreprocessor.js
540 lines (485 loc) · 19.7 KB
/
projectPreprocessor.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
const log = require("@ui5/logger").getLogger("normalizer:projectPreprocessor");
const fs = require("graceful-fs");
const path = require("path");
const {promisify} = require("util");
const readFile = promisify(fs.readFile);
const parseYaml = require("js-yaml").safeLoadAll;
const typeRepository = require("@ui5/builder").types.typeRepository;
class ProjectPreprocessor {
constructor() {
this.processedProjects = {};
this.configShims = {};
this.collections = {};
this.appliedExtensions = {};
}
/*
Adapt and enhance the project tree:
- Replace duplicate projects further away from the root with those closer to the root
- Add configuration to projects
*/
async processTree(tree) {
const queue = [{
projects: [tree],
parent: null,
level: 0
}];
const configPromises = [];
let startTime;
if (log.isLevelEnabled("verbose")) {
startTime = process.hrtime();
}
// Breadth-first search to prefer projects closer to root
while (queue.length) {
const {projects, parent, level} = queue.shift(); // Get and remove first entry from queue
// Before processing all projects on a level concurrently, we need to set all of them as being processed.
// This prevents transitive dependencies pointing to the same projects from being processed first
// by the dependency lookahead
const projectsToProcess = projects.filter((project) => {
if (!project.id) {
const parentRefText = parent ? `(child of ${parent.id})` : `(root project)`;
throw new Error(`Encountered project with missing id ${parentRefText}`);
}
if (this.isBeingProcessed(parent, project)) {
return false;
}
// Flag this project as being processed
this.processedProjects[project.id] = {
project,
// If a project is referenced multiple times in the dependency tree it is replaced
// with the instance that is closest to the root.
// Here we track the parents referencing that project
parents: [parent]
};
return true;
});
await Promise.all(projectsToProcess.map(async (project) => {
project._level = level;
log.verbose(`Processing project ${project.id} on level ${project._level}...`);
if (project.dependencies && project.dependencies.length) {
// Do a dependency lookahead to apply any extensions that might affect this project
await this.dependencyLookahead(project, project.dependencies);
} else {
// When using the static translator for instance dependencies is not defined and will fail later access calls to it
project.dependencies = [];
}
const {extensions} = await this.loadProjectConfiguration(project);
if (extensions && extensions.length) {
// Project contains additional extensions
// => apply them
// TODO: Check whether extensions get applied twice in case depLookahead already processed them
await Promise.all(extensions.map((extProject) => {
return this.applyExtension(extProject);
}));
}
this.applyShims(project);
if (this.isConfigValid(project)) {
await this.applyType(project);
queue.push({
// copy array, so that the queue is stable while ignored project dependencies are removed
projects: [...project.dependencies],
parent: project,
level: level + 1
});
} else {
if (project === tree) {
throw new Error(`Failed to configure root project "${project.id}". Please check verbose log for details.`);
}
// No config available
// => reject this project by removing it from its parents list of dependencies
log.verbose(`Ignoring project ${project.id} with missing configuration ` +
"(might be a non-UI5 dependency)");
const parents = this.processedProjects[project.id].parents;
for (let i = parents.length - 1; i >= 0; i--) {
parents[i].dependencies.splice(parents[i].dependencies.indexOf(project), 1);
}
this.processedProjects[project.id] = {ignored: true};
}
}));
}
return Promise.all(configPromises).then(() => {
if (log.isLevelEnabled("verbose")) {
const prettyHrtime = require("pretty-hrtime");
const timeDiff = process.hrtime(startTime);
log.verbose(`Processed ${Object.keys(this.processedProjects).length} projects in ${prettyHrtime(timeDiff)}`);
}
return tree;
});
}
async dependencyLookahead(parent, dependencies) {
return Promise.all(dependencies.map(async (project) => {
if (this.isBeingProcessed(parent, project)) {
return;
}
log.verbose(`Processing dependency lookahead for ${parent.id}: ${project.id}`);
// Temporarily flag project as being processed
this.processedProjects[project.id] = {
project,
parents: [parent]
};
const {extensions} = await this.loadProjectConfiguration(project);
if (extensions && extensions.length) {
// Project contains additional extensions
// => apply them
await Promise.all(extensions.map((extProject) => {
return this.applyExtension(extProject);
}));
}
if (project.kind === "extension") {
// Not a project but an extension
// => remove it as from any known projects that depend on it
const parents = this.processedProjects[project.id].parents;
for (let i = parents.length - 1; i >= 0; i--) {
parents[i].dependencies.splice(parents[i].dependencies.indexOf(project), 1);
}
// Also ignore it from further processing by other projects depending on it
this.processedProjects[project.id] = {ignored: true};
if (this.isConfigValid(project)) {
// Finally apply the extension
await this.applyExtension(project);
} else {
log.verbose(`Ignoring extension ${project.id} with missing configuration`);
}
} else {
// Project is not an extension: Reset processing status of lookahead to allow the real processing
this.processedProjects[project.id] = null;
}
}));
}
isBeingProcessed(parent, project) { // Check whether a project is currently being or has already been processed
const processedProject = this.processedProjects[project.id];
if (project.deduped) {
// Ignore deduped modules
return true;
}
if (processedProject) {
if (processedProject.ignored) {
log.verbose(`Dependency of project ${parent.id}, "${project.id}" is flagged as ignored.`);
if (parent.dependencies.includes(project)) {
parent.dependencies.splice(parent.dependencies.indexOf(project), 1);
}
return true;
}
log.verbose(`Dependency of project ${parent.id}, "${project.id}": Distance to root of ${parent._level + 1}. Will be `+
`replaced by project with same ID and distance to root of ${processedProject.project._level}.`);
// Replace with the already processed project (closer to root -> preferred)
parent.dependencies[parent.dependencies.indexOf(project)] = processedProject.project;
processedProject.parents.push(parent);
// No further processing needed
return true;
}
return false;
}
async loadProjectConfiguration(project) {
if (project.specVersion) { // Project might already be configured
// Currently, specVersion is the indicator for configured projects
this.normalizeConfig(project);
return {};
}
let configs;
// A projects configPath property takes precedence over the default "<projectPath>/ui5.yaml" path
const configPath = project.configPath || path.join(project.path, "/ui5.yaml");
try {
configs = await this.readConfigFile(configPath);
} catch (err) {
const errorText = "Failed to read configuration for project " +
`${project.id} at "${configPath}". Error: ${err.message}`;
if (err.code !== "ENOENT") { // Something else than "File or directory does not exist"
throw new Error(errorText);
}
log.verbose(errorText);
}
if (!configs || !configs.length) {
return {};
}
for (let i = configs.length - 1; i >= 0; i--) {
this.normalizeConfig(configs[i]);
}
const projectConfigs = configs.filter((config) => {
return config.kind === "project";
});
const extensionConfigs = configs.filter((config) => {
return config.kind === "extension";
});
const projectClone = JSON.parse(JSON.stringify(project));
// While a project can contain multiple configurations,
// from a dependency tree perspective it is always a single project
// This means it can represent one "project", plus multiple extensions or
// one extension, plus multiple extensions
if (projectConfigs.length === 1) {
// All well, this is the one. Merge config into project
Object.assign(project, projectConfigs[0]);
} else if (projectConfigs.length > 1) {
throw new Error(`Found ${projectConfigs.length} configurations of kind 'project' for ` +
`project ${project.id}. There is only one project per configuration allowed.`);
} else if (projectConfigs.length === 0 && extensionConfigs.length) {
// No project, but extensions
// => choose one to represent the project -> the first one
Object.assign(project, extensionConfigs.shift());
} else {
throw new Error(`Found ${configs.length} configurations for ` +
`project ${project.id}. None are of valid kind.`);
}
const extensionProjects = extensionConfigs.map((config) => {
// Clone original project
const configuredProject = JSON.parse(JSON.stringify(projectClone));
// Enhance project with its configuration
Object.assign(configuredProject, config);
return configuredProject;
});
return {extensions: extensionProjects};
}
normalizeConfig(config) {
if (!config.kind) {
config.kind = "project"; // default
}
}
isConfigValid(project) {
if (!project.specVersion) {
if (project._level === 0) {
throw new Error(`No specification version defined for root project ${project.id}`);
}
log.verbose(`No specification version defined for project ${project.id}`);
return false; // ignore this project
}
if (project.specVersion !== "0.1" && project.specVersion !== "1.0" && project.specVersion !== "1.1") {
throw new Error(
`Unsupported specification version ${project.specVersion} defined for project ` +
`${project.id}. Your UI5 CLI installation might be outdated. ` +
`For details see https://sap.github.io/ui5-tooling/pages/Configuration/#specification-versions`);
}
if (!project.type) {
if (project._level === 0) {
throw new Error(`No type configured for root project ${project.id}`);
}
log.verbose(`No type configured for project ${project.id}`);
return false; // ignore this project
}
if (project.kind !== "project" && project._level === 0) {
// This is arguable. It is not the concern of ui5-project to define the entry point of a project tree
// On the other hand, there is no known use case for anything else right now and failing early here
// makes sense in that regard
throw new Error(`Root project needs to be of kind "project". ${project.id} is of kind ${project.kind}`);
}
if (project.kind === "project" && project.type === "application") {
// There must be exactly one application project per dependency tree
// If multiple are found, all but the one closest to the root are rejected (ignored)
// If there are two projects equally close to the root, an error is being thrown
if (!this.qualifiedApplicationProject) {
this.qualifiedApplicationProject = project;
} else if (this.qualifiedApplicationProject._level === project._level) {
throw new Error(`Found at least two projects ${this.qualifiedApplicationProject.id} and ` +
`${project.id} of type application with the same distance to the root project. ` +
"Only one project of type application can be used. Failed to decide which one to ignore.");
} else {
return false; // ignore this project
}
}
return true;
}
async applyType(project) {
let type;
try {
type = typeRepository.getType(project.type);
} catch (err) {
throw new Error(`Failed to retrieve type for project ${project.id}: ${err.message}`);
}
await type.format(project);
}
async applyExtension(extension) {
if (!extension.metadata || !extension.metadata.name) {
throw new Error(`metadata.name configuration is missing for extension ${extension.id}`);
}
log.verbose(`Applying extension ${extension.metadata.name}...`);
if (!extension.specVersion) {
throw new Error(`No specification version defined for extension ${extension.metadata.name}`);
} else if (extension.specVersion !== "0.1" &&
extension.specVersion !== "1.0" &&
extension.specVersion !== "1.1") {
throw new Error(
`Unsupported specification version ${extension.specVersion} defined for extension ` +
`${extension.metadata.name}. Your UI5 CLI installation might be outdated. ` +
`For details see https://sap.github.io/ui5-tooling/pages/Configuration/#specification-versions`);
} else if (this.appliedExtensions[extension.metadata.name]) {
log.verbose(`Extension with the name ${extension.metadata.name} has already been applied. ` +
"This might have been done during dependency lookahead.");
log.verbose(`Already applied extension ID: ${this.appliedExtensions[extension.metadata.name].id}. ` +
`New extension ID: ${extension.id}`);
return;
}
this.appliedExtensions[extension.metadata.name] = extension;
switch (extension.type) {
case "project-shim":
this.handleShim(extension);
break;
case "task":
this.handleTask(extension);
break;
case "server-middleware":
this.handleServerMiddleware(extension);
break;
default:
throw new Error(`Unknown extension type '${extension.type}' for ${extension.id}`);
}
}
async readConfigFile(configPath) {
const configFile = await readFile(configPath);
return parseYaml(configFile, {
filename: path
});
}
handleShim(extension) {
if (!extension.shims) {
throw new Error(`Project shim extension ${extension.id} is missing 'shim' configuration`);
}
const {configurations, dependencies, collections} = extension.shims;
if (configurations) {
log.verbose(`Project shim ${extension.id} contains ` +
`${Object.keys(configurations)} configuration(s)`);
for (const projectId in configurations) {
if (configurations.hasOwnProperty(projectId)) {
this.normalizeConfig(configurations[projectId]); // TODO: Clone object beforehand?
if (this.configShims[projectId]) {
log.verbose(`Project shim ${extension.id}: A configuration shim for project ${projectId} `+
"has already been applied. Skipping.");
} else if (this.isConfigValid(configurations[projectId])) {
log.verbose(`Project shim ${extension.id}: Adding project configuration for ${projectId}...`);
this.configShims[projectId] = configurations[projectId];
} else {
log.verbose(`Project shim ${extension.id}: Ignoring invalid ` +
`configuration shim for project ${projectId}`);
}
}
}
}
if (dependencies) {
// For the time being, shimmed dependencies only apply to shimmed project configurations
for (const projectId in dependencies) {
if (dependencies.hasOwnProperty(projectId)) {
if (this.configShims[projectId]) {
log.verbose(`Project shim ${extension.id}: Adding dependencies ` +
`to project shim '${projectId}'...`);
this.configShims[projectId].dependencies = dependencies[projectId];
} else {
log.verbose(`Project shim ${extension.id}: No configuration shim found for ` +
`project ID '${projectId}'. Dependency shims currently only apply ` +
"to projects with configuration shims.");
}
}
}
}
if (collections) {
log.verbose(`Project shim ${extension.id} contains ` +
`${Object.keys(collections).length} collection(s)`);
for (const projectId in collections) {
if (collections.hasOwnProperty(projectId)) {
if (this.collections[projectId]) {
log.verbose(`Project shim ${extension.id}: A collection with id '${projectId}' `+
"is already known. Skipping.");
} else {
log.verbose(`Project shim ${extension.id}: Adding collection with id '${projectId}'...`);
this.collections[projectId] = collections[projectId];
}
}
}
}
}
applyShims(project) {
const configShim = this.configShims[project.id];
// Apply configuration shims
if (configShim) {
log.verbose(`Applying configuration shim for project ${project.id}...`);
if (configShim.dependencies && configShim.dependencies.length) {
if (!configShim.shimDependenciesResolved) {
configShim.dependencies = configShim.dependencies.map((depId) => {
const depProject = this.processedProjects[depId].project;
if (!depProject) {
throw new Error(
`Failed to resolve shimmed dependency '${depId}' for project ${project.id}. ` +
`Is a dependency with ID '${depId}' part of the dependency tree?`);
}
return depProject;
});
configShim.shimDependenciesResolved = true;
}
configShim.dependencies.forEach((depProject) => {
const parents = this.processedProjects[depProject.id].parents;
if (parents.indexOf(project) === -1) {
parents.push(project);
} else {
log.verbose(`Project ${project.id} is already parent of shimmed dependency ${depProject.id}`);
}
});
}
Object.assign(project, configShim);
delete project.shimDependenciesResolved; // Remove shim processing metadata from project
}
// Apply collections
for (let i = project.dependencies.length - 1; i >= 0; i--) {
const depId = project.dependencies[i].id;
if (this.collections[depId]) {
log.verbose(`Project ${project.id} depends on collection ${depId}. Resolving...`);
// This project depends on a collection
// => replace collection dependency with first collection project.
const collectionDep = project.dependencies[i];
const collectionModules = this.collections[depId].modules;
const projects = [];
for (const projectId in collectionModules) {
if (collectionModules.hasOwnProperty(projectId)) {
// Clone and modify collection "project"
const project = JSON.parse(JSON.stringify(collectionDep));
project.id = projectId;
project.path = path.join(project.path, collectionModules[projectId]);
projects.push(project);
}
}
// Use first collection project to replace the collection dependency
project.dependencies[i] = projects.shift();
// Add any additional collection projects to end of dependency array (already processed)
project.dependencies.push(...projects);
}
}
}
handleTask(extension) {
if (!extension.metadata && !extension.metadata.name) {
throw new Error(`Task extension ${extension.id} is missing 'metadata.name' configuration`);
}
if (!extension.task) {
throw new Error(`Task extension ${extension.id} is missing 'task' configuration`);
}
const taskRepository = require("@ui5/builder").tasks.taskRepository;
const taskPath = path.join(extension.path, extension.task.path);
const task = require(taskPath); // throws if not found
taskRepository.addTask(extension.metadata.name, task);
}
handleServerMiddleware(extension) {
if (!extension.metadata && !extension.metadata.name) {
throw new Error(`Middleware extension ${extension.id} is missing 'metadata.name' configuration`);
}
if (!extension.middleware) {
throw new Error(`Middleware extension ${extension.id} is missing 'middleware' configuration`);
}
const {middlewareRepository} = require("@ui5/server");
const middlewarePath = path.join(extension.path, extension.middleware.path);
middlewareRepository.addMiddleware(extension.metadata.name, middlewarePath);
}
}
/**
* The Project Preprocessor enriches the dependency information with project configuration
*
* @public
* @namespace
* @alias module:@ui5/project.projectPreprocessor
*/
module.exports = {
/**
* Collects project information and its dependencies to enrich it with project configuration
*
* @public
* @param {Object} tree Dependency tree of the project
* @returns {Promise<Object>} Promise resolving with the dependency tree and enriched project configuration
*/
processTree: function(tree) {
return new ProjectPreprocessor().processTree(tree);
},
ProjectPreprocessor
};