project/lib/build/ProjectBuilder.js

import {rimraf} from "rimraf";
import * as resourceFactory from "@ui5/fs/resourceFactory";
import BuildLogger from "@ui5/logger/internal/loggers/Build";
import composeProjectList from "./helpers/composeProjectList.js";
import BuildContext from "./helpers/BuildContext.js";
import prettyHrtime from "pretty-hrtime";
import OutputStyleEnum from "./helpers/ProjectBuilderOutputStyle.js";

/**
 * @public
 * @class
 * @alias @ui5/project/build/ProjectBuilder
 */
class ProjectBuilder {
	#log;
	/**
	 * Build Configuration
	 *
	 * @public
	 * @typedef {object} @ui5/project/build/ProjectBuilder~BuildConfiguration
	 * @property {boolean} [selfContained=false] Flag to activate self contained build
	 * @property {boolean} [cssVariables=false] Flag to activate CSS variables generation
	 * @property {boolean} [jsdoc=false] Flag to activate JSDoc build
	 * @property {boolean} [createBuildManifest=false]
	 *   Whether to create a build manifest file for the root project.
	 *   This is currently only supported for projects of type 'library' and 'theme-library'
	 *   No other dependencies can be included in the build result.
	 * @property {module:@ui5/project/build/ProjectBuilderOutputStyle} [outputStyle=Default]
	 *   Processes build results into a specific directory structure.
	 * @property {Array.<string>} [includedTasks=[]] List of tasks to be included
	 * @property {Array.<string>} [excludedTasks=[]] List of tasks to be excluded.
	 * 			If the wildcard '*' is provided, only the included tasks will be executed.
	 */

	/**
	 * As an alternative to providing plain lists of names of dependencies to include and exclude, you can provide a
	 * more complex "Dependency Includes" object to define which dependencies should be part of the build result.
	 * <br>
	 * This information is then used to compile lists of <code>includedDependencies</code> and
	 * <code>excludedDependencies</code>, which are applied during the build process.
	 * <br><br>
	 * Regular expression-parameters are directly applied to a list of all project dependencies
	 * so that they don't need to be evaluated in later processing steps.
	 * <br><br>
	 * Generally, includes are handled with a higher priority than excludes. Additionally, operations for processing
	 * transitive dependencies are handled with a lower priority than explicitly mentioned dependencies. The "default"
	 * dependency-includes are appended at the end.
	 * <br><br>
	 * The priority of the various dependency lists is applied in the following order.
	 * Note that a later exclude can't overrule an earlier include.
	 * <br>
	 * <ol>
	 *   <li><code>includeDependency</code>, <code>includeDependencyRegExp</code></li>
	 *   <li><code>excludeDependency</code>, <code>excludeDependencyRegExp</code></li>
	 *   <li><code>includeDependencyTree</code></li>
	 *   <li><code>excludeDependencyTree</code></li>
	 *   <li><code>defaultIncludeDependency</code>, <code>defaultIncludeDependencyRegExp</code>,
	 *     <code>defaultIncludeDependencyTree</code></li>
	 * </ol>
	 *
	 * @public
	 * @typedef {object} @ui5/project/build/ProjectBuilder~DependencyIncludes
	 * @property {boolean} includeAllDependencies
	 *   Whether all dependencies should be part of the build result
	 *	 This parameter has the lowest priority and basically includes all remaining (not excluded) projects as include
	 * @property {string[]} includeDependency
	 *   The dependencies to be considered in <code>includedDependencies</code>; the
	 *   <code>*</code> character can be used as wildcard for all dependencies and
	 *   is an alias for the CLI option <code>--all</code>
	 * @property {string[]} includeDependencyRegExp
	 *   Strings which are interpreted as regular expressions
	 *   to describe the selection of dependencies to be considered in <code>includedDependencies</code>
	 * @property {string[]} includeDependencyTree
	 *   The dependencies to be considered in <code>includedDependencies</code>;
	 *   transitive dependencies are also appended
	 * @property {string[]} excludeDependency
	 *   The dependencies to be considered in <code>excludedDependencies</code>
	 * @property {string[]} excludeDependencyRegExp
	 *   Strings which are interpreted as regular expressions
	 *   to describe the selection of dependencies to be considered in <code>excludedDependencies</code>
	 * @property {string[]} excludeDependencyTree
	 *   The dependencies to be considered in <code>excludedDependencies</code>;
	 *   transitive dependencies are also appended
	 * @property {string[]} defaultIncludeDependency
	 *   Same as <code>includeDependency</code> parameter;
	 *   typically used in project build settings
	 * @property {string[]} defaultIncludeDependencyRegExp
	 *   Same as <code>includeDependencyRegExp</code> parameter;
	 *   typically used in project build settings
	 * @property {string[]} defaultIncludeDependencyTree
	 *   Same as <code>includeDependencyTree</code> parameter;
	 *   typically used in project build settings
	 */

	/**
	 * Executes a project build, including all necessary or requested dependencies
	 *
	 * @public
	 * @param {object} parameters
	 * @param {@ui5/project/graph/ProjectGraph} parameters.graph Project graph
	 * @param {@ui5/project/build/ProjectBuilder~BuildConfiguration} [parameters.buildConfig] Build configuration
	 * @param {@ui5/builder/tasks/taskRepository} parameters.taskRepository Task Repository module to use
	 */
	constructor({graph, buildConfig, taskRepository}) {
		if (!graph) {
			throw new Error(`Missing parameter 'graph'`);
		}
		if (!taskRepository) {
			throw new Error(`Missing parameter 'taskRepository'`);
		}
		if (!graph.isSealed()) {
			throw new Error(
				`Can not build project graph with root node ${graph.getRoot().getName()}: Graph is not sealed`);
		}

		this._graph = graph;
		this._buildContext = new BuildContext(graph, taskRepository, buildConfig);
		this.#log = new BuildLogger("ProjectBuilder");
	}

	/**
	 * Executes a project build, including all necessary or requested dependencies
	 *
	 * @public
	 * @param {object} parameters Parameters
	 * @param {string} parameters.destPath Target path
	 * @param {boolean} [parameters.cleanDest=false] Decides whether project should clean the target path before build
	 * @param {Array.<string|RegExp>} [parameters.includedDependencies=[]]
	 *   List of names of projects to include in the build result
	 *   If the wildcard '*' is provided, all dependencies will be included in the build result.
	 * @param {Array.<string|RegExp>} [parameters.excludedDependencies=[]]
	 *   List of names of projects to exclude from the build result.
	 * @param {@ui5/project/build/ProjectBuilder~DependencyIncludes} [parameters.dependencyIncludes]
	 *   Alternative to the <code>includedDependencies</code> and <code>excludedDependencies</code> parameters.
	 *   Allows for a more sophisticated configuration for defining which dependencies should be
	 *   part of the build result. If this is provided, the other mentioned parameters are ignored.
	 * @returns {Promise} Promise resolving once the build has finished
	 */
	async build({
		destPath, cleanDest = false,
		includedDependencies = [], excludedDependencies = [],
		dependencyIncludes
	}) {
		if (!destPath) {
			throw new Error(`Missing parameter 'destPath'`);
		}
		if (dependencyIncludes) {
			if (includedDependencies.length || excludedDependencies.length) {
				throw new Error(
					"Parameter 'dependencyIncludes' can't be used in conjunction " +
					"with parameters 'includedDependencies' or 'excludedDependencies");
			}
		}
		const rootProjectName = this._graph.getRoot().getName();
		this.#log.info(`Preparing build for project ${rootProjectName}`);
		this.#log.info(`  Target directory: ${destPath}`);

		// Get project filter function based on include/exclude params
		// (also logs some info to console)
		const filterProject = await this._getProjectFilter({
			explicitIncludes: includedDependencies,
			explicitExcludes: excludedDependencies,
			dependencyIncludes
		});

		// Count total number of projects to build based on input
		const requestedProjects = this._graph.getProjectNames().filter(function(projectName) {
			return filterProject(projectName);
		});

		if (requestedProjects.length > 1) {
			const {createBuildManifest} = this._buildContext.getBuildConfig();
			if (createBuildManifest) {
				throw new Error(
					`It is currently not supported to request the creation of a build manifest ` +
					`while including any dependencies into the build result`);
			}
		}

		const projectBuildContexts = await this._createRequiredBuildContexts(requestedProjects);
		const cleanupSigHooks = this._registerCleanupSigHooks();
		const fsTarget = resourceFactory.createAdapter({
			fsBasePath: destPath,
			virBasePath: "/"
		});

		const queue = [];
		const alreadyBuilt = [];

		// Create build queue based on graph depth-first search to ensure correct build order
		await this._graph.traverseDepthFirst(async ({project}) => {
			const projectName = project.getName();
			const projectBuildContext = projectBuildContexts.get(projectName);
			if (projectBuildContext) {
				// Build context exists
				//	=> This project needs to be built or, in case it has already
				//		been built, it's build result needs to be written out (if requested)
				queue.push(projectBuildContext);
				if (!projectBuildContext.requiresBuild()) {
					alreadyBuilt.push(projectName);
				}
			}
		});

		this.#log.setProjects(queue.map((projectBuildContext) => {
			return projectBuildContext.getProject().getName();
		}));
		if (queue.length > 1) { // Do not log if only the root project is being built
			this.#log.info(`Processing ${queue.length} projects`);
			if (alreadyBuilt.length) {
				this.#log.info(`  Reusing build results of ${alreadyBuilt.length} projects`);
				this.#log.info(`  Building ${queue.length - alreadyBuilt.length} projects`);
			}

			if (this.#log.isLevelEnabled("verbose")) {
				this.#log.verbose(`  Required projects:`);
				this.#log.verbose(`    ${queue
					.map((projectBuildContext) => {
						const projectName = projectBuildContext.getProject().getName();
						let msg;
						if (alreadyBuilt.includes(projectName)) {
							const buildMetadata = projectBuildContext.getBuildMetadata();
							const ts = new Date(buildMetadata.timestamp).toUTCString();
							msg = `*> ${projectName} /// already built at ${ts}`;
						} else {
							msg = `=> ${projectName}`;
						}
						return msg;
					})
					.join("\n    ")}`);
			}
		}

		if (cleanDest) {
			this.#log.info(`Cleaning target directory...`);
			await rimraf(destPath);
		}
		const startTime = process.hrtime();
		try {
			const pWrites = [];
			for (const projectBuildContext of queue) {
				const projectName = projectBuildContext.getProject().getName();
				const projectType = projectBuildContext.getProject().getType();
				this.#log.verbose(`Processing project ${projectName}...`);

				// Only build projects that are not already build (i.e. provide a matching build manifest)
				if (alreadyBuilt.includes(projectName)) {
					this.#log.skipProjectBuild(projectName, projectType);
				} else {
					this.#log.startProjectBuild(projectName, projectType);
					await projectBuildContext.getTaskRunner().runTasks();
					this.#log.endProjectBuild(projectName, projectType);
				}
				if (!requestedProjects.includes(projectName)) {
					// Project has not been requested
					//	=> Its resources shall not be part of the build result
					continue;
				}

				this.#log.verbose(`Writing out files...`);
				pWrites.push(this._writeResults(projectBuildContext, fsTarget));
			}
			await Promise.all(pWrites);
			this.#log.info(`Build succeeded in ${this._getElapsedTime(startTime)}`);
		} catch (err) {
			this.#log.error(`Build failed in ${this._getElapsedTime(startTime)}`);
			throw err;
		} finally {
			this._deregisterCleanupSigHooks(cleanupSigHooks);
			await this._executeCleanupTasks();
		}
	}

	async _createRequiredBuildContexts(requestedProjects) {
		const requiredProjects = new Set(this._graph.getProjectNames().filter((projectName) => {
			return requestedProjects.includes(projectName);
		}));

		const projectBuildContexts = new Map();

		for (const projectName of requiredProjects) {
			this.#log.verbose(`Creating build context for project ${projectName}...`);
			const projectBuildContext = this._buildContext.createProjectContext({
				project: this._graph.getProject(projectName)
			});

			projectBuildContexts.set(projectName, projectBuildContext);

			if (projectBuildContext.requiresBuild()) {
				const taskRunner = projectBuildContext.getTaskRunner();
				const requiredDependencies = await taskRunner.getRequiredDependencies();

				if (requiredDependencies.size === 0) {
					continue;
				}
				// This project needs to be built and required dependencies to be built as well
				this._graph.getDependencies(projectName).forEach((depName) => {
					if (projectBuildContexts.has(depName)) {
						// Build context already exists
						//	=> Dependency will be built
						return;
					}
					if (!requiredDependencies.has(depName)) {
						return;
					}
					// Add dependency to list of projects to build
					requiredProjects.add(depName);
				});
			}
		}

		return projectBuildContexts;
	}

	async _getProjectFilter({
		dependencyIncludes,
		explicitIncludes,
		explicitExcludes
	}) {
		const {includedDependencies, excludedDependencies} = await composeProjectList(
			this._graph,
			dependencyIncludes || {
				includeDependencyTree: explicitIncludes,
				excludeDependencyTree: explicitExcludes
			}
		);

		if (includedDependencies.length) {
			if (includedDependencies.length === this._graph.getSize() - 1) {
				this.#log.info(`  Including all dependencies`);
			} else {
				this.#log.info(`  Requested dependencies:`);
				this.#log.info(`    + ${includedDependencies.join("\n    + ")}`);
			}
		}
		if (excludedDependencies.length) {
			this.#log.info(`  Excluded dependencies:`);
			this.#log.info(`    - ${excludedDependencies.join("\n    + ")}`);
		}

		const rootProjectName = this._graph.getRoot().getName();
		return function projectFilter(projectName) {
			function projectMatchesAny(deps) {
				return deps.some((dep) => dep instanceof RegExp ?
					dep.test(projectName) : dep === projectName);
			}

			if (projectName === rootProjectName) {
				// Always include the root project
				return true;
			}

			if (projectMatchesAny(excludedDependencies)) {
				return false;
			}

			if (includedDependencies.includes("*") || projectMatchesAny(includedDependencies)) {
				return true;
			}

			return false;
		};
	}

	async _writeResults(projectBuildContext, target) {
		const project = projectBuildContext.getProject();
		const taskUtil = projectBuildContext.getTaskUtil();
		const buildConfig = this._buildContext.getBuildConfig();
		const {createBuildManifest, outputStyle} = buildConfig;
		// Output styles are applied only for the root project
		const isRootProject = taskUtil.isRootProject();

		let readerStyle = "dist";
		if (createBuildManifest ||
			(isRootProject && outputStyle === OutputStyleEnum.Namespace && project.getType() === "application")) {
			// Ensure buildtime (=namespaced) style when writing with a build manifest or when explicitly requested
			readerStyle = "buildtime";
		} else if (isRootProject && outputStyle === OutputStyleEnum.Flat) {
			readerStyle = "flat";
		}

		const reader = project.getReader({
			style: readerStyle
		});
		const resources = await reader.byGlob("/**/*");

		if (createBuildManifest) {
			// Create and write a build manifest metadata file
			const {
				default: createBuildManifest
			} = await import("./helpers/createBuildManifest.js");
			const metadata = await createBuildManifest(project, buildConfig, this._buildContext.getTaskRepository());
			await target.write(resourceFactory.createResource({
				path: `/.ui5/build-manifest.json`,
				string: JSON.stringify(metadata, null, "\t")
			}));
		}

		await Promise.all(resources.map((resource) => {
			if (taskUtil.getTag(resource, taskUtil.STANDARD_TAGS.OmitFromBuildResult)) {
				this.#log.silly(`Skipping write of resource tagged as "OmitFromBuildResult": ` +
					resource.getPath());
				return; // Skip target write for this resource
			}
			return target.write(resource);
		}));

		if (isRootProject &&
			outputStyle === OutputStyleEnum.Flat &&
			project.getType() !== "application" /* application type is with a default flat build output structure */) {
			const namespace = project.getNamespace();
			const libraryResourcesPrefix = `/resources/${namespace}/`;
			const testResourcesPrefix = "/test-resources/";
			const namespacedRegex = new RegExp(`/(resources|test-resources)/${namespace}`);
			const processedResourcesSet = resources.reduce((acc, resource) => acc.add(resource.getPath()), new Set());

			// If outputStyle === "Flat", then the FlatReader would have filtered
			// some resources. We now need to get all of the available resources and
			// do an intersection with the processed/bundled ones.
			const defaultReader = project.getReader();
			const defaultResources = await defaultReader.byGlob("/**/*");
			const flatDefaultResources = defaultResources.map((resource) => ({
				flatResource: resource.getPath().replace(namespacedRegex, ""),
				originalPath: resource.getPath(),
			}));

			const skippedResources = flatDefaultResources.filter((resource) => {
				return processedResourcesSet.has(resource.flatResource) === false;
			});

			skippedResources.forEach((resource) => {
				if (resource.originalPath.startsWith(testResourcesPrefix)) {
					this.#log.verbose(
						`Omitting ${resource.originalPath} from build result. File is part of ${testResourcesPrefix}.`
					);
				} else if (!resource.originalPath.startsWith(libraryResourcesPrefix)) {
					this.#log.warn(
						`Omitting ${resource.originalPath} from build result. ` +
							`File is not within project namespace '${namespace}'.`
					);
				}
			});
		}
	}

	async _executeCleanupTasks(force) {
		this.#log.info("Executing cleanup tasks...");

		await this._buildContext.executeCleanupTasks(force);
	}

	_registerCleanupSigHooks() {
		const that = this;
		function createListener(exitCode) {
			return function() {
				// Asynchronously cleanup resources, then exit
				that._executeCleanupTasks(true).then(() => {
					process.exit(exitCode);
				});
			};
		}

		const processSignals = {
			"SIGHUP": createListener(128 + 1),
			"SIGINT": createListener(128 + 2),
			"SIGTERM": createListener(128 + 15),
			"SIGBREAK": createListener(128 + 21)
		};

		for (const signal of Object.keys(processSignals)) {
			process.on(signal, processSignals[signal]);
		}

		// TODO: Also cleanup for unhandled rejections and exceptions?
		// Add additional events like signals since they are registered on the process
		//	event emitter in a similar fashion
		// processSignals["unhandledRejection"] = createListener(1);
		// process.once("unhandledRejection", processSignals["unhandledRejection"]);
		// processSignals["uncaughtException"] = function(err, origin) {
		// 	const fs = require("fs");
		// 	fs.writeSync(
		// 		process.stderr.fd,
		// 		`Caught exception: ${err}\n` +
		// 		`Exception origin: ${origin}`
		// 	);
		// 	createListener(1)();
		// };
		// process.once("uncaughtException", processSignals["uncaughtException"]);

		return processSignals;
	}

	_deregisterCleanupSigHooks(signals) {
		for (const signal of Object.keys(signals)) {
			process.removeListener(signal, signals[signal]);
		}
	}

	/**
	 * Calculates the elapsed build time and returns a prettified output
	 *
	 * @private
	 * @param {Array} startTime Array provided by <code>process.hrtime()</code>
	 * @returns {string} Difference between now and the provided time array as formatted string
	 */
	_getElapsedTime(startTime) {
		const timeDiff = process.hrtime(startTime);
		return prettyHrtime(timeDiff);
	}
}

export default ProjectBuilder;