fs/lib/resourceFactory.js

import path from "node:path";
import {minimatch} from "minimatch";
import DuplexCollection from "./DuplexCollection.js";
import FsAdapter from "./adapters/FileSystem.js";
import MemAdapter from "./adapters/Memory.js";
import ReaderCollection from "./ReaderCollection.js";
import ReaderCollectionPrioritized from "./ReaderCollectionPrioritized.js";
import Resource from "./Resource.js";
import WriterCollection from "./WriterCollection.js";
import Filter from "./readers/Filter.js";
import Link from "./readers/Link.js";
import {getLogger} from "@ui5/logger";
const log = getLogger("resources:resourceFactory");

/**
 * @module @ui5/fs/resourceFactory
 * @description A collection of resource related APIs
 * @public
 */

/**
 * Creates a resource <code>ReaderWriter</code>.
 *
 * If a file system base path is given, file system resource <code>ReaderWriter</code> is returned.
 * In any other case a virtual one.
 *
 * @public
 * @param {object} parameters Parameters
 * @param {string} parameters.virBasePath Virtual base path. Must be absolute, POSIX-style, and must end with a slash
 * @param {string} [parameters.fsBasePath]
 *   File System base path.
 *   If this parameter is supplied, a File System adapter will be created instead of a Memory adapter.
 *   The provided path must be absolute and must use platform-specific path segment separators.
 * @param {string[]} [parameters.excludes] List of glob patterns to exclude
 * @param {object} [parameters.useGitignore=false]
 *   Whether to apply any excludes defined in an optional .gitignore in the base directory.
 *   This parameter only takes effect in conjunction with the <code>fsBasePath</code> parameter.
 * @param {@ui5/project/specifications/Project} [parameters.project] Project this adapter belongs to (if any)
 * @returns {@ui5/fs/adapters/FileSystem|@ui5/fs/adapters/Memory} File System- or Virtual Adapter
 */
export function createAdapter({fsBasePath, virBasePath, project, excludes, useGitignore}) {
	if (fsBasePath) {
		return new FsAdapter({fsBasePath, virBasePath, project, excludes, useGitignore});
	} else {
		return new MemAdapter({virBasePath, project, excludes});
	}
}

/**
 * Creates a File System adapter and wraps it in a ReaderCollection
 *
 * @public
 * @param {object} parameters Parameters
 * @param {string} parameters.virBasePath Virtual base path. Must be absolute, POSIX-style, and must end with a slash
 * @param {string} parameters.fsBasePath
 *   File System base path. Must be absolute and must use platform-specific path segment separators
 * @param {object} [parameters.project] Experimental, internal parameter. Do not use
 * @param {string[]} [parameters.excludes] List of glob patterns to exclude
 * @param {string} [parameters.name] Name for the reader collection
 * @returns {@ui5/fs/ReaderCollection} Reader collection wrapping an adapter
 */
export function createReader({fsBasePath, virBasePath, project, excludes = [], name}) {
	if (!fsBasePath) {
		// Creating a reader with a memory adapter seems pointless right now
		// since there would be no way to fill the adapter with resources
		throw new Error(`Unable to create reader: Missing parameter "fsBasePath"`);
	}
	let normalizedExcludes = excludes;
	// If a project is supplied, and that project is of type application,
	// Prefix all exclude patterns with the virtual base path (unless it already starts with that)
	// TODO 4.0: // TODO specVersion 4.0: Disallow excludes without namespaced prefix in configuration
	// Specifying an exclude for "/test" is disambigous as it neither reflects the source path nor the
	// ui5 runtime path of the excluded resources. Therefore, only allow paths like /resources/<namespace>/test
	// starting with specVersion 4.0
	if (excludes.length && project && project.getType() === "application") {
		normalizedExcludes = excludes.map((pattern) => {
			if (pattern.startsWith(virBasePath) || pattern.startsWith("!" + virBasePath)) {
				return pattern;
			}
			log.verbose(
				`Prefixing exclude pattern defined in application project ${project.getName()}: ${pattern}`);
			return prefixGlobPattern(pattern, virBasePath);
		});
		// Flatten list of patterns
		normalizedExcludes = Array.prototype.concat.apply([], normalizedExcludes);
		log.verbose(`Effective exclude patterns for application project ${project.getName()}:\n` +
			normalizedExcludes.join(", "));
	}
	return new ReaderCollection({
		name,
		readers: [createAdapter({
			fsBasePath,
			virBasePath,
			project,
			excludes: normalizedExcludes
		})]
	});
}

/**
 * Creates a ReaderCollection
 *
 * @public
 * @param {object} parameters Parameters
 * @param {string} parameters.name The collection name
 * @param {@ui5/fs/AbstractReader[]} parameters.readers List of resource readers (all tried in parallel)
 * @returns {@ui5/fs/ReaderCollection} Reader collection wrapping provided readers
 */
export function createReaderCollection({name, readers}) {
	return new ReaderCollection({
		name,
		readers
	});
}

/**
 * Creates a ReaderCollectionPrioritized
 *
 * @public
 * @param {object} parameters
 * @param {string} parameters.name The collection name
 * @param {@ui5/fs/AbstractReader[]} parameters.readers Prioritized list of resource readers
 * 																(first is tried first)
 * @returns {@ui5/fs/ReaderCollectionPrioritized} Reader collection wrapping provided readers
 */
export function createReaderCollectionPrioritized({name, readers}) {
	return new ReaderCollectionPrioritized({
		name,
		readers
	});
}

/**
 * Creates a WriterCollection
 *
 * @public
 * @param {object} parameters
 * @param {string} parameters.name The collection name
 * @param {object.<string, @ui5/fs/AbstractReaderWriter>} parameters.writerMapping Mapping of virtual base
 * 	paths to writers. Path are matched greedy
 * @returns {@ui5/fs/WriterCollection} Writer collection wrapping provided writers
 */
export function createWriterCollection({name, writerMapping}) {
	return new WriterCollection({
		name,
		writerMapping
	});
}

/**
 * Creates a [Resource]{@link @ui5/fs/Resource}.
 * Accepts the same parameters as the [Resource]{@link @ui5/fs/Resource} constructor.
 *
 * @public
 * @param {object} parameters Parameters to be passed to the resource constructor
 * @returns {@ui5/fs/Resource} Resource
 */
export function createResource(parameters) {
	return new Resource(parameters);
}

/**
 * Creates a Workspace
 *
 * A workspace is a DuplexCollection which reads from the project sources. It is used during the build process
 * to write modified files into a separate writer, this is usually a Memory adapter. If a file already exists it is
 * fetched from the memory to work on it in further build steps.
 *
 * @public
 * @param {object} parameters
 * @param {@ui5/fs/AbstractReader} parameters.reader Single reader or collection of readers
 * @param {@ui5/fs/AbstractReaderWriter} [parameters.writer] A ReaderWriter instance which is
 *        only used for writing files. If not supplied, a Memory adapter will be created.
 * @param {string} [parameters.name="workspace"] Name of the collection
 * @param {string} [parameters.virBasePath="/"] Virtual base path
 * @returns {@ui5/fs/DuplexCollection} DuplexCollection which wraps the provided resource locators
 */
export function createWorkspace({reader, writer, virBasePath = "/", name = "workspace"}) {
	if (!writer) {
		writer = new MemAdapter({
			virBasePath
		});
	}

	return new DuplexCollection({
		reader,
		writer,
		name
	});
}

/**
 * Create a [Filter-Reader]{@link @ui5/fs/readers/Filter} with the given reader.
 * The provided callback is called for every resource that is retrieved through the
 * reader and decides whether the resource shall be passed on or dropped.
 *
 * @public
 * @param {object} parameters
 * @param {@ui5/fs/AbstractReader} parameters.reader Single reader or collection of readers
 * @param {@ui5/fs/readers/Filter~callback} parameters.callback
 * 				Filter function. Will be called for every resource passed through this reader.
 * @returns {@ui5/fs/readers/Filter} Reader instance
 */
export function createFilterReader(parameters) {
	return new Filter(parameters);
}

/**
 * Create a [Link-Reader]{@link @ui5/fs/readers/Filter} with the given reader.
 * The provided path mapping allows for rewriting paths segments of all resources passed through it.
 *
 * @example
 * import {createLinkReader} from "@ui5/fs/resourceFactory";
 * const linkedReader = createLinkReader({
 *     reader: sourceReader,
 *     pathMapping: {
 *          linkPath: `/app`,
 *          targetPath: `/resources/my-app-name/`
 *      }
 * });
 *
 * // The following resolves with a @ui5/fs/ResourceFacade of the resource
 * // located at "/resources/my-app-name/Component.js" in the sourceReader
 * const resource = await linkedReader.byPath("/app/Component.js");
 *
 * @public
 * @param {object} parameters
 * @param {@ui5/fs/AbstractReader} parameters.reader Single reader or collection of readers
 * @param {@ui5/fs/readers/Link/PathMapping} parameters.pathMapping
 * @returns {@ui5/fs/readers/Link} Reader instance
 */
export function createLinkReader(parameters) {
	return new Link(parameters);
}

/**
 * Create a [Link-Reader]{@link @ui5/fs/readers/Link} where all requests are prefixed with
 * <code>/resources/<namespace></code>.
 *
 * This simulates "flat" resource access, which is for example common for projects of type
 * "application".
 *
 * @public
 * @param {object} parameters
 * @param {@ui5/fs/AbstractReader} parameters.reader Single reader or collection of readers
 * @param {string} parameters.namespace Project namespace
 * @returns {@ui5/fs/readers/Link} Reader instance
 */
export function createFlatReader({reader, namespace}) {
	return new Link({
		reader: reader,
		pathMapping: {
			linkPath: `/`,
			targetPath: `/resources/${namespace}/`
		}
	});
}

/**
 * Normalizes virtual glob patterns by prefixing them with
 * a given virtual base directory path
 *
 * @param {string} virPattern glob pattern for virtual directory structure
 * @param {string} virBaseDir virtual base directory path to prefix the given patterns with
 * @returns {string[]} A list of normalized glob patterns
 */
export function prefixGlobPattern(virPattern, virBaseDir) {
	const mm = new minimatch.Minimatch(virPattern);

	const resultGlobs = [];
	for (let i = 0; i < mm.globSet.length; i++) {
		let resultPattern = path.posix.join(virBaseDir, mm.globSet[i]);

		if (mm.negate) {
			resultPattern = "!" + resultPattern;
		}
		resultGlobs.push(resultPattern);
	}
	return resultGlobs;
}