You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
500 lines
14 KiB
500 lines
14 KiB
/* |
|
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<Buffer | string>): void) & function(string, object, FileSystemCallback<Buffer | string>): void} readFile |
|
* @property {(function(string, FileSystemCallback<(Buffer | string)[] | FileSystemDirent[]>): void) & function(string, object, FileSystemCallback<(Buffer | string)[] | FileSystemDirent[]>): void} readdir |
|
* @property {((function(string, FileSystemCallback<object>): void) & function(string, object, FileSystemCallback<object>): void)=} readJson |
|
* @property {(function(string, FileSystemCallback<Buffer | string>): void) & function(string, object, FileSystemCallback<Buffer | string>): void} readlink |
|
* @property {(function(string, FileSystemCallback<FileSystemStats>): void) & function(string, object, FileSystemCallback<Buffer | string>): void=} lstat |
|
* @property {(function(string, FileSystemCallback<FileSystemStats>): void) & function(string, object, FileSystemCallback<Buffer | string>): 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<ParsedIdentifier>} ResolveRequest */ |
|
|
|
/** |
|
* String with special formatting |
|
* @typedef {string} StackEntry |
|
*/ |
|
|
|
/** @template T @typedef {{ add: (T) => void }} WriteOnlySet */ |
|
|
|
/** |
|
* Resolve context |
|
* @typedef {Object} ResolveContext |
|
* @property {WriteOnlySet<string>=} contextDependencies |
|
* @property {WriteOnlySet<string>=} fileDependencies files that was found on file system |
|
* @property {WriteOnlySet<string>=} missingDependencies dependencies that was not found on file system |
|
* @property {Set<StackEntry>=} 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;
|
|
|