Skip to content

Commit a4609d4

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 7769590 commit a4609d4

File tree

2 files changed

+280
-75
lines changed

2 files changed

+280
-75
lines changed

lib/projectPreprocessor.js

+201-70
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,209 @@ 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;
74-
for (var i = parents.length - 1; i >= 0; i--) {
81+
const parents = this.processedProjects[project.id].parents;
82+
for (let i = parents.length - 1; i >= 0; i--) {
7583
parents[i].dependencies.splice(parents[i].dependencies.indexOf(project), 1);
7684
}
77-
processedProjects[project.id] = {ignored: true};
85+
this.processedProjects[project.id] = {ignored: true};
7886
}
7987
}));
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-
}
9088
}
9189
return Promise.all(configPromises).then(() => {
9290
if (log.isLevelEnabled("verbose")) {
9391
const prettyHrtime = require("pretty-hrtime");
9492
const timeDiff = process.hrtime(startTime);
95-
log.verbose(`Processed ${Object.keys(processedProjects).length} projects in ${prettyHrtime(timeDiff)}`);
93+
log.verbose(`Processed ${Object.keys(this.processedProjects).length} projects in ${prettyHrtime(timeDiff)}`);
9694
}
9795
return tree;
9896
});
9997
}
10098

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

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

229+
isConfigValid(project) {
113230
if (!project.specVersion) {
114231
if (project._level === 0) {
115232
throw new Error(`No specification version defined for root project ${project.id}`);
116233
}
117234
log.verbose(`No specification version defined for project ${project.id}`);
118-
return; // return with empty config
235+
return false; // ignore this project
119236
}
120237

121238
if (project.specVersion !== "0.1") {
@@ -128,21 +245,40 @@ class ProjectPreprocessor {
128245
if (project._level === 0) {
129246
throw new Error(`No type configured for root project ${project.id}`);
130247
}
131-
log.verbose(`No type configured for project ${project.id} (neither in project configuration, nor in any shim)`);
132-
return; // return with empty config
248+
log.verbose(`No type configured for project ${project.id}`);
249+
return false; // ignore this project
133250
}
134251

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

143-
// Apply type
144-
await this.applyType(project);
145-
return project;
280+
async applyExtension(project) {
281+
// TOOD
146282
}
147283

148284
async getProjectConfiguration(project) {
@@ -182,11 +318,6 @@ class ProjectPreprocessor {
182318
filename: path
183319
});
184320
}
185-
186-
async applyType(project) {
187-
let type = typeRepository.getType(project.type);
188-
return type.format(project);
189-
}
190321
}
191322

192323
/**

0 commit comments

Comments
 (0)