Skip to content

Commit 935c654

Browse files
committed
[INTERNAL] Groundwork for 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 a988310 commit 935c654

File tree

2 files changed

+280
-74
lines changed

2 files changed

+280
-74
lines changed

lib/projectPreprocessor.js

Lines changed: 201 additions & 69 deletions
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,210 @@ 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+
this.processedProjects[project.id] = {
46+
project,
47+
// If a project is referenced multiple times in the dependency tree it is replaced
48+
// with the instance that is closest to the root.
49+
// Here we track the parents referencing that project
50+
parents: [parent]
51+
};
52+
return true;
53+
});
4654

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);
55+
await Promise.all(projectsToProcess.map(async (project) => {
56+
log.verbose(`Processing project ${project.id} on level ${project._level}...`);
5057

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

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-
};
60+
if (project.dependencies && project.dependencies.length) {
61+
await this.dependencyLookahead(project, project.dependencies);
62+
}
6263

63-
configPromises.push(this.configureProject(project).then((config) => {
64-
if (!config) {
64+
await this.loadProjectConfiguration(project);
65+
// this.applyShims(project); // shims not yet implemented
66+
if (this.isConfigValid(project)) {
67+
await this.applyType(project);
68+
queue.push({
69+
projects: project.dependencies,
70+
parent: project,
71+
level: level + 1
72+
});
73+
} else {
6574
if (project === tree) {
6675
throw new Error(`Failed to configure root project "${project.id}". Please check verbose log for details.`);
6776
}
68-
6977
// No config available
7078
// => reject this project by removing it from its parents list of dependencies
71-
log.verbose(`Ignoring project ${project.id} with missing configuration `+
79+
log.verbose(`Ignoring project ${project.id} with missing configuration ` +
7280
"(might be a non-UI5 dependency)");
73-
const parents = processedProjects[project.id].parents;
81+
82+
const parents = this.processedProjects[project.id].parents;
7483
for (let i = parents.length - 1; i >= 0; i--) {
7584
parents[i].dependencies.splice(parents[i].dependencies.indexOf(project), 1);
7685
}
77-
processedProjects[project.id] = {ignored: true};
86+
this.processedProjects[project.id] = {ignored: true};
7887
}
7988
}));
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-
}
9089
}
9190
return Promise.all(configPromises).then(() => {
9291
if (log.isLevelEnabled("verbose")) {
9392
const prettyHrtime = require("pretty-hrtime");
9493
const timeDiff = process.hrtime(startTime);
95-
log.verbose(`Processed ${Object.keys(processedProjects).length} projects in ${prettyHrtime(timeDiff)}`);
94+
log.verbose(`Processed ${Object.keys(this.processedProjects).length} projects in ${prettyHrtime(timeDiff)}`);
9695
}
9796
return tree;
9897
});
9998
}
10099

101-
async configureProject(project) {
102-
if (!project.specVersion) { // Project might already be configured (e.g. via inline configuration)
100+
async dependencyLookahead(parent, dependencies) {
101+
return Promise.all(dependencies.map(async (project) => {
102+
if (this.isBeingProcessed(project, project)) {
103+
return;
104+
}
105+
log.verbose(`Processing dependency lookahead for ${parent.id}: ${project.id}`);
106+
this.processedProjects[project.id] = {
107+
project,
108+
parents: [parent]
109+
};
110+
const extensions = await this.loadProjectConfiguration(project);
111+
if (extensions && extensions.length) {
112+
await Promise.all(extensions.map((extProject) => {
113+
return this.applyExtension(extProject);
114+
}));
115+
}
116+
117+
if (project.kind === "extension" && this.isConfigValid(project)) {
118+
const parents = this.processedProjects[project.id].parents;
119+
for (let i = parents.length - 1; i >= 0; i--) {
120+
parents[i].dependencies.splice(parents[i].dependencies.indexOf(project), 1);
121+
}
122+
this.processedProjects[project.id] = {ignored: true};
123+
await this.applyExtension(project);
124+
} else {
125+
// No extension: Reset processing status of lookahead to allow the real processing
126+
this.processedProjects[project.id] = null;
127+
}
128+
}));
129+
}
130+
131+
isBeingProcessed(parent, project) { // Check whether a project is currently being or has already been processed
132+
const processedProject = this.processedProjects[project.id];
133+
if (processedProject) {
134+
if (processedProject.ignored) {
135+
log.verbose(`Dependency of project ${parent.id}, "${project.id}" is flagged as ignored.`);
136+
parent.dependencies.splice(parent.dependencies.indexOf(project), 1);
137+
return true;
138+
}
139+
log.verbose(`Dependency of project ${parent.id}, "${project.id}": Distance to root of ${parent._level + 1}. Will be `+
140+
`replaced by project with same ID and distance to root of ${processedProject.project._level}.`);
141+
142+
// Replace with the already processed project (closer to root -> preferred)
143+
parent.dependencies[parent.dependencies.indexOf(project)] = processedProject.project;
144+
processedProject.parents.push(parent);
145+
146+
// No further processing needed
147+
return true;
148+
}
149+
return false;
150+
}
151+
152+
async loadProjectConfiguration(project) {
153+
if (project.specVersion) { // Project might already be configured
103154
// Currently, specVersion is the indicator for configured projects
104-
const projectConf = await this.getProjectConfiguration(project);
155+
this.normalizeConfig(project);
156+
return;
157+
}
105158

106-
if (!projectConf) {
107-
return null;
159+
let configs;
160+
161+
// A projects configPath property takes precedence over the default "<projectPath>/ui5.yaml" path
162+
const configPath = project.configPath || path.join(project.path, "/ui5.yaml");
163+
try {
164+
configs = await this.readConfigFile(configPath);
165+
} catch (err) {
166+
const errorText = "Failed to read configuration for project " +
167+
`${project.id} at "${configPath}". Error: ${err.message}`;
168+
169+
if (err.code !== "ENOENT") { // Something else than "File or directory does not exist"
170+
throw new Error(errorText);
108171
}
172+
log.verbose(errorText);
173+
}
174+
175+
if (!configs || !configs.length) {
176+
return;
177+
}
178+
179+
for (let i = configs.length - 1; i >= 0; i--) {
180+
this.normalizeConfig(configs[i]);
181+
}
182+
183+
const projectConfigs = configs.filter((config) => {
184+
return config.kind === "project";
185+
});
186+
187+
const extensionConfigs = configs.filter((config) => {
188+
return config.kind === "extension";
189+
});
190+
191+
const projectClone = JSON.parse(JSON.stringify(project));
192+
193+
// While a project can contain multiple configurations,
194+
// from a dependency tree perspective it is always a single project
195+
// This means it can represent one "project", plus multiple extensions or
196+
// one extension, plus multiple extensions
197+
198+
if (projectConfigs.length === 1) {
199+
// All well, this is the one
200+
Object.assign(project, projectConfigs[0]);
201+
} else if (projectConfigs.length > 1) {
202+
throw new Error(`Found ${projectConfigs.length} configurations of kind 'project' for ` +
203+
`project ${project.id}. There is only one allowed.`);
204+
} else if (projectConfigs.length === 0 && extensionConfigs.length) {
205+
// No project, but extensions
206+
// => choose one to represent the project (the first one)
207+
Object.assign(project, extensionConfigs.shift());
208+
} else {
209+
throw new Error(`Found ${configs.length} configurations for ` +
210+
`project ${project.id}. None are of valid kind.`);
211+
}
212+
213+
const extensionProjects = extensionConfigs.map((config) => {
214+
// Clone original project
215+
const configuredProject = JSON.parse(JSON.stringify(projectClone));
216+
109217
// Enhance project with its configuration
110-
Object.assign(project, projectConf);
218+
Object.assign(configuredProject, config);
219+
});
220+
221+
return extensionProjects;
222+
}
223+
224+
normalizeConfig(config) {
225+
if (!config.kind) {
226+
config.kind = "project"; // default
111227
}
228+
}
112229

230+
isConfigValid(project) {
113231
if (!project.specVersion) {
114232
if (project._level === 0) {
115233
throw new Error(`No specification version defined for root project ${project.id}`);
116234
}
117235
log.verbose(`No specification version defined for project ${project.id}`);
118-
return; // return with empty config
236+
return false; // ignore this project
119237
}
120238

121239
if (project.specVersion !== "0.1") {
@@ -128,21 +246,40 @@ class ProjectPreprocessor {
128246
if (project._level === 0) {
129247
throw new Error(`No type configured for root project ${project.id}`);
130248
}
131-
log.verbose(`No type configured for project ${project.id} (neither in project configuration, nor in any shim)`);
132-
return; // return with empty config
249+
log.verbose(`No type configured for project ${project.id}`);
250+
return false; // ignore this project
133251
}
134252

135-
if (project.type === "application" && project._level !== 0) {
136-
// There is only one project of type application allowed
253+
if (project.kind !== "project" && project._level === 0) {
254+
// This is arguable. It is not the concern of ui5-project to define the entry point of a project tree
255+
// On the other hand, there is no known use case for anything else right now and failing early here
256+
// makes sense in that regard
257+
throw new Error(`Root project needs to be of kind "project". ${project.id} is of kind ${project.kind}`);
258+
}
259+
260+
if (project.kind === "project" && project.type === "application" && project._level !== 0) {
261+
// There is only one project project of type application allowed
137262
// That project needs to be the root project
138263
log.verbose(`[Warn] Ignoring project ${project.id} with type application`+
139264
` (distance to root: ${project._level}). Type application is only allowed for the root project`);
140-
return; // return with empty config
265+
return false; // ignore this project
266+
}
267+
268+
return true;
269+
}
270+
271+
async applyType(project) {
272+
let type;
273+
try {
274+
type = typeRepository.getType(project.type);
275+
} catch (err) {
276+
throw new Error(`Failed to retrieve type for project ${project.id}: ${err.message}`);
141277
}
278+
await type.format(project);
279+
}
142280

143-
// Apply type
144-
await this.applyType(project);
145-
return project;
281+
async applyExtension(project) {
282+
// TOOD
146283
}
147284

148285
async getProjectConfiguration(project) {
@@ -182,11 +319,6 @@ class ProjectPreprocessor {
182319
filename: path
183320
});
184321
}
185-
186-
async applyType(project) {
187-
let type = typeRepository.getType(project.type);
188-
return type.format(project);
189-
}
190322
}
191323

192324
/**

0 commit comments

Comments
 (0)