project/lib/specifications/Specification.js

import path from "node:path";
import {getLogger} from "@ui5/logger";
import {createReader} from "@ui5/fs/resourceFactory";
import SpecificationVersion from "./SpecificationVersion.js";

/**
 * Abstract superclass for all projects and extensions
 *
 * @public
 * @abstract
 * @class
 * @alias @ui5/project/specifications/Specification
 * @hideconstructor
 */
class Specification {
	/**
	 * Create a Specification instance for the given parameters
	 *
	 * @param {object} parameters
	 * @param {string} parameters.id Unique ID
	 * @param {string} parameters.version Version
	 * @param {string} parameters.modulePath Absolute File System path to access resources
	 * @param {object} parameters.configuration
	 *   Type-dependent configuration object. Typically defined in a ui5.yaml
	 * @static
	 * @public
	 */
	static async create(parameters) {
		if (!parameters.configuration) {
			throw new Error(
				`Unable to create Specification instance: Missing configuration parameter`);
		}
		const {kind, type} = parameters.configuration;
		if (!["project", "extension"].includes(kind)) {
			throw new Error(`Unable to create Specification instance: Unknown kind '${kind}'`);
		}

		switch (type) {
		case "application": {
			return createAndInitializeSpec("types/Application.js", parameters);
		}
		case "library": {
			return createAndInitializeSpec("types/Library.js", parameters);
		}
		case "theme-library": {
			return createAndInitializeSpec("types/ThemeLibrary.js", parameters);
		}
		case "module": {
			return createAndInitializeSpec("types/Module.js", parameters);
		}
		case "task": {
			return createAndInitializeSpec("extensions/Task.js", parameters);
		}
		case "server-middleware": {
			return createAndInitializeSpec("extensions/ServerMiddleware.js", parameters);
		}
		case "project-shim": {
			return createAndInitializeSpec("extensions/ProjectShim.js", parameters);
		}
		default:
			throw new Error(
				`Unable to create Specification instance: Unknown specification type '${type}'`);
		}
	}

	constructor() {
		if (new.target === Specification) {
			throw new TypeError("Class 'Specification' is abstract. Please use one of the 'types' subclasses");
		}
		this._log = getLogger(`specifications:types:${this.constructor.name}`);
	}

	/**
	 * @param {object} parameters Specification parameters
	 * @param {string} parameters.id Unique ID
	 * @param {string} parameters.version Version
	 * @param {string} parameters.modulePath Absolute File System path to access resources
	 * @param {object} parameters.configuration Configuration object
	 */
	async init({id, version, modulePath, configuration}) {
		if (!id) {
			throw new Error(`Could not create Specification: Missing or empty parameter 'id'`);
		}
		if (!version) {
			throw new Error(`Could not create Specification: Missing or empty parameter 'version'`);
		}
		if (!modulePath) {
			throw new Error(`Could not create Specification: Missing or empty parameter 'modulePath'`);
		}
		if (!path.isAbsolute(modulePath)) {
			throw new Error(`Could not create Specification: Parameter 'modulePath' must contain an absolute path`);
		}
		if (!configuration) {
			throw new Error(`Could not create Specification: Missing or empty parameter 'configuration'`);
		}

		this._version = version;
		this._modulePath = modulePath;

		// The ID property is filled from the provider (e.g. package.json "name") and might differ between providers.
		// It is mainly used to detect framework libraries marked by @openui5 / @sapui5 scopes of npm package.
		// (see Project#isFrameworkProject)
		// In general, the configured name (metadata.name) should be used instead as the unique identifier of a project.
		this.__id = id;

		// Deep clone config to prevent changes by reference
		const config = JSON.parse(JSON.stringify(configuration));
		const {validate} = await import("../validation/validator.js");

		if (SpecificationVersion.major(config.specVersion) <= 1) {
			const originalSpecVersion = config.specVersion;
			this._log.verbose(`Detected legacy Specification Version ${config.specVersion}, defined for ` +
				`${config.kind} ${config.metadata.name}. ` +
				`Attempting to migrate the project to a supported specification version...`);
			this._migrateLegacyProject(config);
			try {
				await validate({
					config,
					project: {
						id
					}
				});
			} catch (err) {
				this._log.verbose(
					`Validation error after migration of ${config.kind} ${config.metadata.name}:`);
				this._log.verbose(err.message);
				throw new Error(
					`${config.kind} ${config.metadata.name} defines unsupported Specification Version ` +
					`${originalSpecVersion}. Please manually upgrade to 3.0 or higher. ` +
					`For details see https://sap.github.io/ui5-tooling/pages/Configuration/#specification-versions - ` +
					`An attempted migration to a supported specification version failed, ` +
					`likely due to unrecognized configuration. Check verbose log for details.`);
			}
		} else {
			await validate({
				config,
				project: {
					id
				}
			});
		}

		// Check whether the given configuration matches the class by guessing the type name from the class name
		if (config.type.replace("-", "") !== this.constructor.name.toLowerCase()) {
			throw new Error(
				`Configuration mismatch: Supplied configuration of type '${config.type}' does not match with ` +
				`specification class ${this.constructor.name}`);
		}

		this._name = config.metadata.name;
		this._kind = config.kind;
		this._type = config.type;
		this._specVersionString = config.specVersion;
		this._specVersion = new SpecificationVersion(this._specVersionString);
		this._config = config;

		return this;
	}

	/* === Attributes === */
	/**
	 * Gets the ID of this specification.
	 *
	 * <p><b>Note: </b>Only to be used for special occasions as it is specific to the provider that was used and does
	 * not necessarily represent something defined by the project.</p>
	 *
	 * For general purposes of a unique identifier use
	 * {@link @ui5/project/specifications/Specification#getName getName} instead.
	 *
	 * @public
	 * @returns {string} Specification ID
	 */
	getId() {
		return this.__id;
	}

	/**
	 * Gets the name of this specification. Represents a unique identifier.
	 *
	 * @public
	 * @returns {string} Specification name
	 */
	getName() {
		return this._name;
	}

	/**
	 * Gets the kind of this specification, for example <code>project</code> or <code>extension</code>
	 *
	 * @public
	 * @returns {string} Specification kind
	 */
	getKind() {
		return this._kind;
	}

	/**
	 * Gets the type of this specification,
	 * for example <code>application</code> or <code>library</code> in case of projects,
	 * and <code>task</code> or <code>server-middleware</code> in case of extensions
	 *
	 * @public
	 * @returns {string} Specification type
	 */
	getType() {
		return this._type;
	}

	/**
	 * Returns an instance of a helper class representing a Specification Version
	 *
	 * @public
	 * @returns {@ui5/project/specifications/SpecificationVersion}
	 */
	getSpecVersion() {
		return this._specVersion;
	}

	/**
	 * Gets the specification's generic version, as typically defined in a <code>package.json</code>
	 *
	 * @public
	 * @returns {string} Project version
	 */
	getVersion() {
		return this._version;
	}

	/**
	 * Gets the specification's file system path. This might not be POSIX-style on some platforms
	 *
	 * @public
	 * @returns {string} Project root path
	 */
	getRootPath() {
		return this._modulePath;
	}

	/* === Resource Access === */
	/**
	 * Gets a [ReaderCollection]{@link @ui5/fs/ReaderCollection} for the root directory of the specification.
	 * Resource readers always use POSIX-style
	 *
	 * @public
	 * @param {object} [parameters] Parameters
	 * @param {object} [parameters.useGitignore=true]
	 *   Whether to apply any excludes defined in an optional .gitignore in the root directory
	 * @returns {@ui5/fs/ReaderCollection} Reader collection
	*/
	getRootReader({useGitignore=true} = {}) {
		return createReader({
			fsBasePath: this.getRootPath(),
			virBasePath: "/",
			name: `Root reader for ${this.getType()} ${this.getKind()} ${this.getName()}`,
			useGitignore
		});
	}

	/* === Internals === */
	/* === Helper === */
	/**
	 * @private
	 * @param {string} dirPath Directory path, relative to the specification root
	*/
	async _dirExists(dirPath) {
		const resource = await this.getRootReader().byPath(dirPath, {nodir: false});
		if (resource && resource.getStatInfo().isDirectory()) {
			return true;
		}
		return false;
	}

	_migrateLegacyProject(config) {
		// Stick to 2.6 since 3.0 adds further restrictions (i.e. for the name) and enables
		// functionality for extensions that shouldn't be enabled if the specVersion is not
		// explicitly set to 3.x
		config.specVersion = "2.6";

		// propertiesFileSourceEncoding (relevant for applications and libraries) default
		// has been changed to UTF-8 with specVersion 2.0
		// Adding back the old default if no configuration is provided.
		if (config.kind === "project" && ["application", "library"].includes(config.type) &&
				!config.resources?.configuration?.propertiesFileSourceEncoding) {
			config.resources = config.resources || {};
			config.resources.configuration = config.resources.configuration || {};
			config.resources.configuration.propertiesFileSourceEncoding = "ISO-8859-1";
		}
	}
}

async function createAndInitializeSpec(moduleName, params) {
	const {default: Spec} = await import(`./${moduleName}`);
	return new Spec().init(params);
}

export default Specification;