project/lib/graph/Workspace.js

import fs from "graceful-fs";
import {globby, isDynamicPattern} from "globby";
import path from "node:path";
import {promisify} from "node:util";
import {getLogger} from "@ui5/logger";
import Module from "./Module.js";
import {validateWorkspace} from "../validation/validator.js";

const readFile = promisify(fs.readFile);
const log = getLogger("graph:Workspace");


/**
 * Workspace configuration. For details, refer to the
 * [UI5 Workspaces documentation]{@link https://sap.github.io/ui5-tooling/v4/pages/Workspace/#configuration}
 *
 * @public
 * @typedef {object} @ui5/project/graph/Workspace~Configuration
 * @property {string} node.specVersion Workspace Specification Version
 * @property {object} node.metadata
 * @property {string} node.metadata.name Name of the workspace configuration
 * @property {object} node.dependencyManagement
 * @property {@ui5/project/graph/Workspace~DependencyManagementResolutions[]} node.dependencyManagement.resolutions
 */

/**
 * A resolution entry for the dependency management section of the workspace configuration
 *
 * @public
 * @typedef {object} @ui5/project/graph/Workspace~DependencyManagementResolution
 * @property {string} path Relative path to use for the workspace resolution process
 */

/**
 * UI5 Workspace
 *
 * @public
 * @class
 * @alias @ui5/project/graph/Workspace
 */
class Workspace {
	#visitedNodePaths = new Set();
	#configValidated = false;
	#configuration;
	#cwd;

	/**
	 * @public
	 * @param {object} options
	 * @param {string} options.cwd Path to use for resolving all paths of the workspace configuration from.
	 *   This should contain platform-specific path separators (i.e. must not be POSIX on non-POSIX systems)
	 * @param {@ui5/project/graph/Workspace~Configuration} options.configuration
	 *   Workspace configuration
	 */
	constructor({cwd, configuration}) {
		if (!cwd) {
			throw new Error(`Could not create Workspace: Missing or empty parameter 'cwd'`);
		}
		if (!configuration) {
			throw new Error(`Could not create Workspace: Missing or empty parameter 'configuration'`);
		}

		this.#cwd = cwd;
		this.#configuration = configuration;
	}

	/**
	 * Get the name of this workspace
	 *
	 * @public
	 * @returns {string} Name of this workspace configuration
	 */
	getName() {
		return this.#configuration.metadata.name;
	}

	/**
	 * Returns an array of [Module]{@ui5/project/graph/Module} instances found in the configured
	 * dependency-management resolution paths of this workspace, sorted by module ID.
	 *
	 * @public
	 * @returns {Promise<@ui5/project/graph/Module[]>}
	 *   Array of Module instances sorted by module ID
	 */
	async getModules() {
		const {moduleIdMap} = await this._getResolvedModules();
		const sortedMap = new Map([...moduleIdMap].sort((a, b) => String(a[0]).localeCompare(b[0])));
		return Array.from(sortedMap.values());
	}

	/**
	 * For a given project name (e.g. the value of the <code>metadata.name</code> property in a ui5.yaml),
	 * returns a [Module]{@ui5/project/graph/Module} instance or <code>undefined</code> depending on whether the project
	 * has been found in the configured dependency-management resolution paths of this workspace
	 *
	 * @public
	 * @param {string} projectName Name of the project
	 * @returns {Promise<@ui5/project/graph/Module|undefined>}
	 *   Module instance, or <code>undefined</code> if none is found
	 */
	async getModuleByProjectName(projectName) {
		const {projectNameMap} = await this._getResolvedModules();
		return projectNameMap.get(projectName);
	}

	/**
	 * For a given node id (e.g. the value of the name property in a package.json),
	 * returns a [Module]{@ui5/project/graph/Module} instance or <code>undefined</code> depending on whether the module
	 * has been found in the configured dependency-management resolution paths of this workspace
	 * and contains at least one project or extension
	 *
	 * @public
	 * @param {string} nodeId Node ID of the module
	 * @returns {Promise<@ui5/project/graph/Module|undefined>}
	 *   Module instance, or <code>undefined</code> if none is found
	 */
	async getModuleByNodeId(nodeId) {
		const {moduleIdMap} = await this._getResolvedModules();
		return moduleIdMap.get(nodeId);
	}

	_getResolvedModules() {
		if (this._pResolvedModules) {
			return this._pResolvedModules;
		}

		return this._pResolvedModules = this._resolveModules();
	}

	async _resolveModules() {
		await this._validateConfig();
		const resolutions = this.#configuration.dependencyManagement?.resolutions;
		if (!resolutions?.length) {
			return {
				projectNameMap: new Map(),
				moduleIdMap: new Map()
			};
		}

		let resolvedModules = await Promise.all(resolutions.map(async (resolutionConfig) => {
			if (!resolutionConfig.path) {
				throw new Error(
					`Missing property 'path' in dependency resolution configuration of workspace ${this.getName()}`);
			}
			return await this._getModulesFromPath(
				this.#cwd, resolutionConfig.path);
		}));

		// Flatten array since package-workspaces might have resolved to multiple modules for a single resolution
		resolvedModules = Array.prototype.concat.apply([], resolvedModules);

		const projectNameMap = new Map();
		const moduleIdMap = new Map();
		await Promise.all(resolvedModules.map(async (module) => {
			const {project, extensions} = await module.getSpecifications();
			if (project || extensions.length) {
				moduleIdMap.set(module.getId(), module);
			} else {
				log.warn(
					`Failed to create a project or extensions from module ${module.getId()} at ${module.getPath()}`);
			}
			if (project) {
				projectNameMap.set(project.getName(), module);
				log.verbose(`Module ${module.getId()} contains project ${project.getName()}`);
			}
			if (extensions.length) {
				const extensionNames = extensions.map((e) => e.getName()).join(", ");
				log.verbose(`Module ${module.getId()} contains extensions: ${extensionNames}`);
			}
		}));
		return {
			projectNameMap,
			moduleIdMap
		};
	}

	async _getModulesFromPath(cwd, relPath, failOnMissingFiles = true) {
		const nodePath = path.join(cwd, relPath);
		if (this.#visitedNodePaths.has(nodePath)) {
			log.verbose(`Module located at ${nodePath} has already been visited`);
			return [];
		}
		this.#visitedNodePaths.add(nodePath);
		let pkg;
		try {
			pkg = await this._readPackageJson(nodePath);
			if (!pkg?.name || !pkg?.version) {
				throw new Error(
					`package.json must contain fields 'name' and 'version'`);
			}
		} catch (err) {
			if (!failOnMissingFiles && err.code === "ENOENT") {
				// When resolving a dynamic workspace pattern (not a static path), ignore modules that
				// are missing a package.json (this might simply indicate an empty directory)
				log.verbose(`Ignoring module at path ${nodePath}: Directory does not contain a package.json`);
				return [];
			}
			throw new Error(
				`Failed to resolve workspace dependency resolution path ${relPath} to ${nodePath}: ${err.message}`);
		}

		// If the package.json defines an npm "workspaces", or an equivalent "ui5.workspaces" configuration,
		// resolve the workspace and only use the resulting modules. The root package is ignored.
		const packageWorkspaceConfig = pkg.ui5?.workspaces || pkg.workspaces;
		if (packageWorkspaceConfig?.length) {
			log.verbose(`Module ${pkg.name} provides a package.json workspaces configuration. ` +
				`Ignoring the module and resolving workspaces instead...`);
			const staticPatterns = [];
			// Split provided patterns into dynamic and static patterns
			// This is necessary, since fast-glob currently behaves different from
			// 	"glob" (used by @npmcli/map-workspaces) in that it does not match the
			// 	base directory in case it is equal to the pattern (https://github.com/mrmlnc/fast-glob/issues/47)
			// For example a pattern "package-a" would not match a directory called
			// 	"package-a" in the root directory of the project.
			// 	We therefore detect the static pattern and resolve it directly
			const dynamicPatterns = packageWorkspaceConfig.filter((pattern) => {
				if (isDynamicPattern(pattern)) {
					return true;
				} else {
					staticPatterns.push(pattern);
					return false;
				}
			});

			let searchPaths = [];
			if (dynamicPatterns.length) {
				searchPaths = await globby(dynamicPatterns, {
					cwd: nodePath,
					followSymbolicLinks: false,
					onlyDirectories: true,
				});
			}
			searchPaths.push(...staticPatterns);

			const resolvedModules = new Map();
			await Promise.all(searchPaths.map(async (pkgPath) => {
				const modules = await this._getModulesFromPath(nodePath, pkgPath, staticPatterns.includes(pkgPath));
				modules.forEach((module) => {
					const id = module.getId();
					if (!resolvedModules.get(id)) {
						resolvedModules.set(id, module);
					}
				});
			}));
			return Array.from(resolvedModules.values());
		} else {
			return [new Module({
				id: pkg.name,
				version: pkg.version,
				modulePath: nodePath
			})];
		}
	}

	/**
	 * Reads the package.json file and returns its content
	 *
	 * @private
	 * @param {string} modulePath Path to the module containing the package.json
	 * @returns {object} Package json content
	 */
	async _readPackageJson(modulePath) {
		const content = await readFile(path.join(modulePath, "package.json"), "utf8");
		return JSON.parse(content);
	}

	async _validateConfig() {
		if (this.#configValidated) {
			return;
		}
		await validateWorkspace({
			config: this.#configuration
		});
		this.#configValidated = true;
	}
}

export default Workspace;