/* MIT License http://www.opensource.org/licenses/mit-license.php Author Tobias Koppers @sokra */ "use strict"; const { AsyncSeriesBailHook, AsyncSeriesHook, SyncHook } = require("tapable"); const createInnerContext = require("./createInnerContext"); const { parseIdentifier } = require("./util/identifier"); const { normalize, cachedJoin: join, getType, PathType } = require("./util/path"); /** @typedef {import("./ResolverFactory").ResolveOptions} ResolveOptions */ /** * @typedef {Object} FileSystemStats * @property {function(): boolean} isDirectory * @property {function(): boolean} isFile */ /** * @typedef {Object} FileSystemDirent * @property {Buffer | string} name * @property {function(): boolean} isDirectory * @property {function(): boolean} isFile */ /** * @typedef {Object} PossibleFileSystemError * @property {string=} code * @property {number=} errno * @property {string=} path * @property {string=} syscall */ /** * @template T * @callback FileSystemCallback * @param {PossibleFileSystemError & Error | null | undefined} err * @param {T=} result */ /** * @typedef {Object} FileSystem * @property {(function(string, FileSystemCallback): void) & function(string, object, FileSystemCallback): void} readFile * @property {(function(string, FileSystemCallback<(Buffer | string)[] | FileSystemDirent[]>): void) & function(string, object, FileSystemCallback<(Buffer | string)[] | FileSystemDirent[]>): void} readdir * @property {((function(string, FileSystemCallback): void) & function(string, object, FileSystemCallback): void)=} readJson * @property {(function(string, FileSystemCallback): void) & function(string, object, FileSystemCallback): void} readlink * @property {(function(string, FileSystemCallback): void) & function(string, object, FileSystemCallback): void=} lstat * @property {(function(string, FileSystemCallback): void) & function(string, object, FileSystemCallback): void} stat */ /** * @typedef {Object} SyncFileSystem * @property {function(string, object=): Buffer | string} readFileSync * @property {function(string, object=): (Buffer | string)[] | FileSystemDirent[]} readdirSync * @property {(function(string, object=): object)=} readJsonSync * @property {function(string, object=): Buffer | string} readlinkSync * @property {function(string, object=): FileSystemStats=} lstatSync * @property {function(string, object=): FileSystemStats} statSync */ /** * @typedef {Object} ParsedIdentifier * @property {string} request * @property {string} query * @property {string} fragment * @property {boolean} directory * @property {boolean} module * @property {boolean} file * @property {boolean} internal */ /** * @typedef {Object} BaseResolveRequest * @property {string | false} path * @property {string=} descriptionFilePath * @property {string=} descriptionFileRoot * @property {object=} descriptionFileData * @property {string=} relativePath * @property {boolean=} ignoreSymlinks * @property {boolean=} fullySpecified */ /** @typedef {BaseResolveRequest & Partial} ResolveRequest */ /** * String with special formatting * @typedef {string} StackEntry */ /** @template T @typedef {{ add: (T) => void }} WriteOnlySet */ /** * Resolve context * @typedef {Object} ResolveContext * @property {WriteOnlySet=} contextDependencies * @property {WriteOnlySet=} fileDependencies files that was found on file system * @property {WriteOnlySet=} missingDependencies dependencies that was not found on file system * @property {Set=} stack set of hooks' calls. For instance, `resolve → parsedResolve → describedResolve`, * @property {(function(string): void)=} log log function * @property {(function (ResolveRequest): void)=} yield yield result, if provided plugins can return several results */ /** @typedef {AsyncSeriesBailHook<[ResolveRequest, ResolveContext], ResolveRequest | null>} ResolveStepHook */ /** * @param {string} str input string * @returns {string} in camel case */ function toCamelCase(str) { return str.replace(/-([a-z])/g, str => str.substr(1).toUpperCase()); } class Resolver { /** * @param {ResolveStepHook} hook hook * @param {ResolveRequest} request request * @returns {StackEntry} stack entry */ static createStackEntry(hook, request) { return ( hook.name + ": (" + request.path + ") " + (request.request || "") + (request.query || "") + (request.fragment || "") + (request.directory ? " directory" : "") + (request.module ? " module" : "") ); } /** * @param {FileSystem} fileSystem a filesystem * @param {ResolveOptions} options options */ constructor(fileSystem, options) { this.fileSystem = fileSystem; this.options = options; this.hooks = { /** @type {SyncHook<[ResolveStepHook, ResolveRequest], void>} */ resolveStep: new SyncHook(["hook", "request"], "resolveStep"), /** @type {SyncHook<[ResolveRequest, Error]>} */ noResolve: new SyncHook(["request", "error"], "noResolve"), /** @type {ResolveStepHook} */ resolve: new AsyncSeriesBailHook( ["request", "resolveContext"], "resolve" ), /** @type {AsyncSeriesHook<[ResolveRequest, ResolveContext]>} */ result: new AsyncSeriesHook(["result", "resolveContext"], "result") }; } /** * @param {string | ResolveStepHook} name hook name or hook itself * @returns {ResolveStepHook} the hook */ ensureHook(name) { if (typeof name !== "string") { return name; } name = toCamelCase(name); if (/^before/.test(name)) { return /** @type {ResolveStepHook} */ (this.ensureHook( name[6].toLowerCase() + name.substr(7) ).withOptions({ stage: -10 })); } if (/^after/.test(name)) { return /** @type {ResolveStepHook} */ (this.ensureHook( name[5].toLowerCase() + name.substr(6) ).withOptions({ stage: 10 })); } const hook = this.hooks[name]; if (!hook) { return (this.hooks[name] = new AsyncSeriesBailHook( ["request", "resolveContext"], name )); } return hook; } /** * @param {string | ResolveStepHook} name hook name or hook itself * @returns {ResolveStepHook} the hook */ getHook(name) { if (typeof name !== "string") { return name; } name = toCamelCase(name); if (/^before/.test(name)) { return /** @type {ResolveStepHook} */ (this.getHook( name[6].toLowerCase() + name.substr(7) ).withOptions({ stage: -10 })); } if (/^after/.test(name)) { return /** @type {ResolveStepHook} */ (this.getHook( name[5].toLowerCase() + name.substr(6) ).withOptions({ stage: 10 })); } const hook = this.hooks[name]; if (!hook) { throw new Error(`Hook ${name} doesn't exist`); } return hook; } /** * @param {object} context context information object * @param {string} path context path * @param {string} request request string * @returns {string | false} result */ resolveSync(context, path, request) { /** @type {Error | null | undefined} */ let err = undefined; /** @type {string | false | undefined} */ let result = undefined; let sync = false; this.resolve(context, path, request, {}, (e, r) => { err = e; result = r; sync = true; }); if (!sync) { throw new Error( "Cannot 'resolveSync' because the fileSystem is not sync. Use 'resolve'!" ); } if (err) throw err; if (result === undefined) throw new Error("No result"); return result; } /** * @param {object} context context information object * @param {string} path context path * @param {string} request request string * @param {ResolveContext} resolveContext resolve context * @param {function(Error | null, (string|false)=, ResolveRequest=): void} callback callback function * @returns {void} */ resolve(context, path, request, resolveContext, callback) { if (!context || typeof context !== "object") return callback(new Error("context argument is not an object")); if (typeof path !== "string") return callback(new Error("path argument is not a string")); if (typeof request !== "string") return callback(new Error("request argument is not a string")); if (!resolveContext) return callback(new Error("resolveContext argument is not set")); const obj = { context: context, path: path, request: request }; let yield_; let yieldCalled = false; let finishYield; if (typeof resolveContext.yield === "function") { const old = resolveContext.yield; yield_ = obj => { old(obj); yieldCalled = true; }; finishYield = result => { if (result) yield_(result); callback(null); }; } const message = `resolve '${request}' in '${path}'`; const finishResolved = result => { return callback( null, result.path === false ? false : `${result.path.replace(/#/g, "\0#")}${ result.query ? result.query.replace(/#/g, "\0#") : "" }${result.fragment || ""}`, result ); }; const finishWithoutResolve = log => { /** * @type {Error & {details?: string}} */ const error = new Error("Can't " + message); error.details = log.join("\n"); this.hooks.noResolve.call(obj, error); return callback(error); }; if (resolveContext.log) { // We need log anyway to capture it in case of an error const parentLog = resolveContext.log; const log = []; return this.doResolve( this.hooks.resolve, obj, message, { log: msg => { parentLog(msg); log.push(msg); }, yield: yield_, fileDependencies: resolveContext.fileDependencies, contextDependencies: resolveContext.contextDependencies, missingDependencies: resolveContext.missingDependencies, stack: resolveContext.stack }, (err, result) => { if (err) return callback(err); if (yieldCalled || (result && yield_)) return finishYield(result); if (result) return finishResolved(result); return finishWithoutResolve(log); } ); } else { // Try to resolve assuming there is no error // We don't log stuff in this case return this.doResolve( this.hooks.resolve, obj, message, { log: undefined, yield: yield_, fileDependencies: resolveContext.fileDependencies, contextDependencies: resolveContext.contextDependencies, missingDependencies: resolveContext.missingDependencies, stack: resolveContext.stack }, (err, result) => { if (err) return callback(err); if (yieldCalled || (result && yield_)) return finishYield(result); if (result) return finishResolved(result); // log is missing for the error details // so we redo the resolving for the log info // this is more expensive to the success case // is assumed by default const log = []; return this.doResolve( this.hooks.resolve, obj, message, { log: msg => log.push(msg), yield: yield_, stack: resolveContext.stack }, (err, result) => { if (err) return callback(err); // In a case that there is a race condition and yield will be called if (yieldCalled || (result && yield_)) return finishYield(result); return finishWithoutResolve(log); } ); } ); } } doResolve(hook, request, message, resolveContext, callback) { const stackEntry = Resolver.createStackEntry(hook, request); let newStack; if (resolveContext.stack) { newStack = new Set(resolveContext.stack); if (resolveContext.stack.has(stackEntry)) { /** * Prevent recursion * @type {Error & {recursion?: boolean}} */ const recursionError = new Error( "Recursion in resolving\nStack:\n " + Array.from(newStack).join("\n ") ); recursionError.recursion = true; if (resolveContext.log) resolveContext.log("abort resolving because of recursion"); return callback(recursionError); } newStack.add(stackEntry); } else { newStack = new Set([stackEntry]); } this.hooks.resolveStep.call(hook, request); if (hook.isUsed()) { const innerContext = createInnerContext( { log: resolveContext.log, yield: resolveContext.yield, fileDependencies: resolveContext.fileDependencies, contextDependencies: resolveContext.contextDependencies, missingDependencies: resolveContext.missingDependencies, stack: newStack }, message ); return hook.callAsync(request, innerContext, (err, result) => { if (err) return callback(err); if (result) return callback(null, result); callback(); }); } else { callback(); } } /** * @param {string} identifier identifier * @returns {ParsedIdentifier} parsed identifier */ parse(identifier) { const part = { request: "", query: "", fragment: "", module: false, directory: false, file: false, internal: false }; const parsedIdentifier = parseIdentifier(identifier); if (!parsedIdentifier) return part; [part.request, part.query, part.fragment] = parsedIdentifier; if (part.request.length > 0) { part.internal = this.isPrivate(identifier); part.module = this.isModule(part.request); part.directory = this.isDirectory(part.request); if (part.directory) { part.request = part.request.substr(0, part.request.length - 1); } } return part; } isModule(path) { return getType(path) === PathType.Normal; } isPrivate(path) { return getType(path) === PathType.Internal; } /** * @param {string} path a path * @returns {boolean} true, if the path is a directory path */ isDirectory(path) { return path.endsWith("/"); } join(path, request) { return join(path, request); } normalize(path) { return normalize(path); } } module.exports = Resolver;