Skip to content

Commit 476b785

Browse files
committed
[FEATURE] Add "extension" projects
See [RFC0001](SAP/ui5-tooling#4). Identify and apply extensions in dependency tree so that they can influence the actual project processing.
1 parent 640d087 commit 476b785

File tree

2 files changed

+288
-99
lines changed

2 files changed

+288
-99
lines changed

lib/projectPreprocessor.js

+209-94
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,22 @@ const fs = require("graceful-fs");
33
const path = require("path");
44
const {promisify} = require("util");
55
const readFile = promisify(fs.readFile);
6-
const parseYaml = require("js-yaml").safeLoad;
6+
const parseYaml = require("js-yaml").safeLoadAll;
77
const typeRepository = require("@ui5/builder").types.typeRepository;
88

99
class ProjectPreprocessor {
10+
constructor() {
11+
this.processedProjects = {};
12+
}
13+
1014
/*
1115
Adapt and enhance the project tree:
12-
- Replace duplicate projects further away from the root with those closed to the root
16+
- Replace duplicate projects further away from the root with those closer to the root
1317
- Add configuration to projects
1418
*/
1519
async processTree(tree) {
16-
const processedProjects = {};
1720
const queue = [{
18-
project: tree,
21+
projects: [tree],
1922
parent: null,
2023
level: 0
2124
}];
@@ -27,95 +30,224 @@ class ProjectPreprocessor {
2730

2831
// Breadth-first search to prefer projects closer to root
2932
while (queue.length) {
30-
const {project, parent, level} = queue.shift(); // Get and remove first entry from queue
31-
if (!project.id) {
32-
throw new Error("Encountered project with missing id");
33-
}
34-
project._level = level;
35-
36-
// Check whether project ID is already known
37-
const processedProject = processedProjects[project.id];
38-
if (processedProject) {
39-
if (processedProject.ignored) {
40-
log.verbose(`Dependency of project ${parent.id}, "${project.id}" is flagged as ignored.`);
41-
parent.dependencies.splice(parent.dependencies.indexOf(project), 1);
42-
continue;
33+
const {projects, parent, level} = queue.shift(); // Get and remove first entry from queue
34+
35+
// Before processing all projects on a level concurrently, we need to set all of them as being processed.
36+
// This prevents transitive dependencies pointing to the same projects from being processed first
37+
// by the dependency lookahead
38+
const projectsToProcess = projects.filter((project) => {
39+
if (!project.id) {
40+
throw new Error("Encountered project with missing id");
41+
}
42+
if (this.isBeingProcessed(parent, project)) {
43+
return false;
4344
}
44-
log.verbose(`Dependency of project ${parent.id}, "${project.id}": Distance to root of ${level}. Will be `+
45-
`replaced by project with same ID and distance to root of ${processedProject.project._level}.`);
45+
// Flag this project as being processed
46+
this.processedProjects[project.id] = {
47+
project,
48+
// If a project is referenced multiple times in the dependency tree it is replaced
49+
// with the instance that is closest to the root.
50+
// Here we track the parents referencing that project
51+
parents: [parent]
52+
};
53+
return true;
54+
});
4655

47-
// Replace with the already processed project (closer to root -> preferred)
48-
parent.dependencies[parent.dependencies.indexOf(project)] = processedProject.project;
49-
processedProject.parents.push(parent);
56+
await Promise.all(projectsToProcess.map(async (project) => {
57+
log.verbose(`Processing project ${project.id} on level ${project._level}...`);
5058

51-
// No further processing needed
52-
continue;
53-
}
59+
project._level = level;
5460

55-
processedProjects[project.id] = {
56-
project,
57-
// If a project is referenced multiple times in the dependency tree,
58-
// it is replaced with the occurrence closest to the root.
59-
// Here we collect the different parents, this single project configuration then has
60-
parents: [parent]
61-
};
61+
if (project.dependencies && project.dependencies.length) {
62+
// Do a dependency lookahead to apply any extensions that might affect this project
63+
await this.dependencyLookahead(project, project.dependencies);
64+
}
6265

63-
configPromises.push(this.configureProject(project).then((config) => {
64-
if (!config) {
66+
await this.loadProjectConfiguration(project);
67+
// this.applyShims(project); // shims not yet implemented
68+
if (this.isConfigValid(project)) {
69+
await this.applyType(project);
70+
queue.push({
71+
projects: project.dependencies,
72+
parent: project,
73+
level: level + 1
74+
});
75+
} else {
6576
if (project === tree) {
6677
throw new Error(`Failed to configure root project "${project.id}". Please check verbose log for details.`);
6778
}
68-
6979
// No config available
7080
// => reject this project by removing it from its parents list of dependencies
71-
log.verbose(`Ignoring project ${project.id} with missing configuration `+
81+
log.verbose(`Ignoring project ${project.id} with missing configuration ` +
7282
"(might be a non-UI5 dependency)");
73-
const parents = processedProjects[project.id].parents;
83+
84+
const parents = this.processedProjects[project.id].parents;
7485
for (let i = parents.length - 1; i >= 0; i--) {
7586
parents[i].dependencies.splice(parents[i].dependencies.indexOf(project), 1);
7687
}
77-
processedProjects[project.id] = {ignored: true};
88+
this.processedProjects[project.id] = {ignored: true};
7889
}
7990
}));
80-
81-
if (project.dependencies) {
82-
queue.push(...project.dependencies.map((depProject) => {
83-
return {
84-
project: depProject,
85-
parent: project,
86-
level: level + 1
87-
};
88-
}));
89-
}
9091
}
9192
return Promise.all(configPromises).then(() => {
9293
if (log.isLevelEnabled("verbose")) {
9394
const prettyHrtime = require("pretty-hrtime");
9495
const timeDiff = process.hrtime(startTime);
95-
log.verbose(`Processed ${Object.keys(processedProjects).length} projects in ${prettyHrtime(timeDiff)}`);
96+
log.verbose(`Processed ${Object.keys(this.processedProjects).length} projects in ${prettyHrtime(timeDiff)}`);
9697
}
9798
return tree;
9899
});
99100
}
100101

101-
async configureProject(project) {
102-
if (!project.specVersion) { // Project might already be configured (e.g. via inline configuration)
102+
async dependencyLookahead(parent, dependencies) {
103+
return Promise.all(dependencies.map(async (project) => {
104+
if (this.isBeingProcessed(parent, project)) {
105+
return;
106+
}
107+
log.verbose(`Processing dependency lookahead for ${parent.id}: ${project.id}`);
108+
// Temporarily flag project as being processed
109+
this.processedProjects[project.id] = {
110+
project,
111+
parents: [parent]
112+
};
113+
const {extensions} = await this.loadProjectConfiguration(project);
114+
if (extensions && extensions.length) {
115+
// Project contains additional extensions
116+
// => apply them
117+
await Promise.all(extensions.map((extProject) => {
118+
return this.applyExtension(extProject);
119+
}));
120+
}
121+
122+
if (project.kind === "extension") {
123+
// Not a project but an extension
124+
// => remove it as from any known projects that depend on it
125+
const parents = this.processedProjects[project.id].parents;
126+
for (let i = parents.length - 1; i >= 0; i--) {
127+
parents[i].dependencies.splice(parents[i].dependencies.indexOf(project), 1);
128+
}
129+
// Also ignore it from further processing by other projects depending on it
130+
this.processedProjects[project.id] = {ignored: true};
131+
132+
if (this.isConfigValid(project)) {
133+
// Finally apply the extension
134+
await this.applyExtension(project);
135+
} else {
136+
log.verbose(`Ignoring extension ${project.id} with missing configuration`);
137+
}
138+
} else {
139+
// Project is not an extension: Reset processing status of lookahead to allow the real processing
140+
this.processedProjects[project.id] = null;
141+
}
142+
}));
143+
}
144+
145+
isBeingProcessed(parent, project) { // Check whether a project is currently being or has already been processed
146+
const processedProject = this.processedProjects[project.id];
147+
if (processedProject) {
148+
if (processedProject.ignored) {
149+
log.verbose(`Dependency of project ${parent.id}, "${project.id}" is flagged as ignored.`);
150+
parent.dependencies.splice(parent.dependencies.indexOf(project), 1);
151+
return true;
152+
}
153+
log.verbose(`Dependency of project ${parent.id}, "${project.id}": Distance to root of ${parent._level + 1}. Will be `+
154+
`replaced by project with same ID and distance to root of ${processedProject.project._level}.`);
155+
156+
// Replace with the already processed project (closer to root -> preferred)
157+
parent.dependencies[parent.dependencies.indexOf(project)] = processedProject.project;
158+
processedProject.parents.push(parent);
159+
160+
// No further processing needed
161+
return true;
162+
}
163+
return false;
164+
}
165+
166+
async loadProjectConfiguration(project) {
167+
if (project.specVersion) { // Project might already be configured
103168
// Currently, specVersion is the indicator for configured projects
104-
const projectConf = await this.getProjectConfiguration(project);
169+
this.normalizeConfig(project);
170+
return {};
171+
}
172+
173+
let configs;
174+
175+
// A projects configPath property takes precedence over the default "<projectPath>/ui5.yaml" path
176+
const configPath = project.configPath || path.join(project.path, "/ui5.yaml");
177+
try {
178+
configs = await this.readConfigFile(configPath);
179+
} catch (err) {
180+
const errorText = "Failed to read configuration for project " +
181+
`${project.id} at "${configPath}". Error: ${err.message}`;
105182

106-
if (!projectConf) {
107-
return null;
183+
if (err.code !== "ENOENT") { // Something else than "File or directory does not exist"
184+
throw new Error(errorText);
108185
}
186+
log.verbose(errorText);
187+
}
188+
189+
if (!configs || !configs.length) {
190+
return {};
191+
}
192+
193+
for (let i = configs.length - 1; i >= 0; i--) {
194+
this.normalizeConfig(configs[i]);
195+
}
196+
197+
const projectConfigs = configs.filter((config) => {
198+
return config.kind === "project";
199+
});
200+
201+
const extensionConfigs = configs.filter((config) => {
202+
return config.kind === "extension";
203+
});
204+
205+
const projectClone = JSON.parse(JSON.stringify(project));
206+
207+
// While a project can contain multiple configurations,
208+
// from a dependency tree perspective it is always a single project
209+
// This means it can represent one "project", plus multiple extensions or
210+
// one extension, plus multiple extensions
211+
212+
if (projectConfigs.length === 1) {
213+
// All well, this is the one. Merge config into project
214+
Object.assign(project, projectConfigs[0]);
215+
} else if (projectConfigs.length > 1) {
216+
throw new Error(`Found ${projectConfigs.length} configurations of kind 'project' for ` +
217+
`project ${project.id}. There is only one project per configuration allowed.`);
218+
} else if (projectConfigs.length === 0 && extensionConfigs.length) {
219+
// No project, but extensions
220+
// => choose one to represent the project -> the first one
221+
Object.assign(project, extensionConfigs.shift());
222+
} else {
223+
throw new Error(`Found ${configs.length} configurations for ` +
224+
`project ${project.id}. None are of valid kind.`);
225+
}
226+
227+
const extensionProjects = extensionConfigs.map((config) => {
228+
// Clone original project
229+
const configuredProject = JSON.parse(JSON.stringify(projectClone));
230+
109231
// Enhance project with its configuration
110-
Object.assign(project, projectConf);
232+
Object.assign(configuredProject, config);
233+
});
234+
235+
return {extensions: extensionProjects};
236+
}
237+
238+
normalizeConfig(config) {
239+
if (!config.kind) {
240+
config.kind = "project"; // default
111241
}
242+
}
112243

244+
isConfigValid(project) {
113245
if (!project.specVersion) {
114246
if (project._level === 0) {
115247
throw new Error(`No specification version defined for root project ${project.id}`);
116248
}
117249
log.verbose(`No specification version defined for project ${project.id}`);
118-
return; // return with empty config
250+
return false; // ignore this project
119251
}
120252

121253
if (project.specVersion !== "0.1") {
@@ -128,52 +260,40 @@ class ProjectPreprocessor {
128260
if (project._level === 0) {
129261
throw new Error(`No type configured for root project ${project.id}`);
130262
}
131-
log.verbose(`No type configured for project ${project.id} (neither in project configuration, nor in any shim)`);
132-
return; // return with empty config
263+
log.verbose(`No type configured for project ${project.id}`);
264+
return false; // ignore this project
133265
}
134266

135-
if (project.type === "application" && project._level !== 0) {
136-
// There is only one project of type application allowed
267+
if (project.kind !== "project" && project._level === 0) {
268+
// This is arguable. It is not the concern of ui5-project to define the entry point of a project tree
269+
// On the other hand, there is no known use case for anything else right now and failing early here
270+
// makes sense in that regard
271+
throw new Error(`Root project needs to be of kind "project". ${project.id} is of kind ${project.kind}`);
272+
}
273+
274+
if (project.kind === "project" && project.type === "application" && project._level !== 0) {
275+
// There is only one project project of type application allowed
137276
// That project needs to be the root project
138277
log.verbose(`[Warn] Ignoring project ${project.id} with type application`+
139278
` (distance to root: ${project._level}). Type application is only allowed for the root project`);
140-
return; // return with empty config
279+
return false; // ignore this project
141280
}
142281

143-
// Apply type
144-
await this.applyType(project);
145-
return project;
282+
return true;
146283
}
147284

148-
async getProjectConfiguration(project) {
149-
// A projects configPath property takes precedence over the default "<projectPath>/ui5.yaml" path
150-
const configPath = project.configPath || path.join(project.path, "/ui5.yaml");
151-
152-
let config;
285+
async applyType(project) {
286+
let type;
153287
try {
154-
config = await this.readConfigFile(configPath);
288+
type = typeRepository.getType(project.type);
155289
} catch (err) {
156-
const errorText = "Failed to read configuration for project " +
157-
`${project.id} at "${configPath}". Error: ${err.message}`;
158-
159-
if (err.code !== "ENOENT") { // Something else than "File or directory does not exist"
160-
throw new Error(errorText);
161-
}
162-
log.verbose(errorText);
163-
164-
/* Disabled shimming until shim-plugin is available
165-
// If there is a config shim, use it as fallback
166-
if (configShims[project.id]) {
167-
// It's ok if there is no project configuration in the project if there is a shim for it
168-
log.verbose(`Applying shim for project ${project.id}...`);
169-
config = JSON.parse(JSON.stringify(configShims[project.id]));
170-
} else {
171-
// No configuration available -> return empty config
172-
return null;
173-
}*/
290+
throw new Error(`Failed to retrieve type for project ${project.id}: ${err.message}`);
174291
}
292+
await type.format(project);
293+
}
175294

176-
return config;
295+
async applyExtension(project) {
296+
// TOOD
177297
}
178298

179299
async readConfigFile(configPath) {
@@ -182,11 +302,6 @@ class ProjectPreprocessor {
182302
filename: path
183303
});
184304
}
185-
186-
async applyType(project) {
187-
let type = typeRepository.getType(project.type);
188-
return type.format(project);
189-
}
190305
}
191306

192307
/**

0 commit comments

Comments
 (0)