hardhat
Version:
Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.
411 lines • 20 kB
JavaScript
import path from "node:path";
import { assertHardhatInvariant } from "@nomicfoundation/hardhat-errors";
import { getAllFilesMatching, readJsonFile, readUtf8File, } from "@nomicfoundation/hardhat-utils/fs";
import { findDependencyPackageJson, } from "@nomicfoundation/hardhat-utils/package";
import { UserRemappingErrorType } from "../../../../../types/solidity.js";
import { getNpmPackageName } from "./npm-module-parsing.js";
import { parseRemappingString, selectBestRemapping } from "./remappings.js";
import { sourceNamePathJoin } from "./source-name-utils.js";
import { UserRemappingType } from "./types.js";
const HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT = "project";
export function isResolvedUserRemapping(remapping) {
return ("type" in remapping &&
(remapping.type === UserRemappingType.NPM ||
remapping.type === UserRemappingType.LOCAL));
}
export class RemappedNpmPackagesGraphImplementation {
/**
* The Hardhat project itself.
*/
#hardhatProjectPackage;
/**
* This is a map of all the npm packages. Every package that has been
* loaded by this class, is present in this map.
*
* Its value is another map, where the keys are the installation name of each
* dependency of the package that has been loaded, and the values are objects
* with the resolved npm package and the remapping that we generate for that
* package -- installationName --> package relationship.
*
* The generated remapping is generated once and stored for each relationship,
* to preserve its uniqueness.
*/
#installationMap = new Map();
/**
* A map of all the npm packages, indexed by their input source name root.
*/
#packageByInputSourceNameRoot = new Map();
/**
* A map of all the user remappings of each npm package.
*/
#userRemappingsPerPackage = new Map();
/**
* A map of all the remappings generated to map a direct import within a
* package to a particular npm file. This is used to generate remappings into
* packages that use package.exports, as we can't generate more generic
* remappings for them.
*/
#generatedRemappingsIntoNpmFiles = new Map();
static async create(projectRootPath) {
const projectPackageJson = await readJsonFile(path.join(projectRootPath, "package.json"));
const resolvedNpmPackage = {
name: projectPackageJson.name,
version: projectPackageJson.version,
exports: projectPackageJson.exports,
rootFsPath: projectRootPath,
inputSourceNameRoot: HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT,
};
return new RemappedNpmPackagesGraphImplementation(resolvedNpmPackage);
}
constructor(hardhatProjectPackage) {
this.#hardhatProjectPackage = hardhatProjectPackage;
this.#insertNewPackage(hardhatProjectPackage);
}
getHardhatProjectPackage() {
return this.#hardhatProjectPackage;
}
/**
* Resolves a dependency of the package `from` by its installation name.
*
* This method modifies the graph, potentially loading new packages, but it
* doesn't read its remappings, and it doesn't take user remappings into
* account.
*
* This method is pretty complex, so read the comments carefully.
*
* @param from The package from which the dependency is being resolved.
* @param installationName The installation name of the dependency.
* @returns The package and generated remappings, or undefined if the
* dependency could not be resolved.
*/
async resolveDependencyByInstallationName(from, installationName) {
// We may need to modify the installation map, so we need to access it.
const npmPackageDependenciesMap = this.#installationMap.get(from);
assertHardhatInvariant(npmPackageDependenciesMap !== undefined, "The npm package must be present in the map");
// If the dependency already exists with this same installation name we
// reuse it.
const existingDependencyNpmPackageByInstallationName = npmPackageDependenciesMap.get(installationName);
if (existingDependencyNpmPackageByInstallationName !== undefined) {
return existingDependencyNpmPackageByInstallationName;
}
// Otherwise, we try to get it's package.json to:
// 1) Load it if necessary.
// 2) Add it to the installation map.
const dependencyPackageJsonPath = await findDependencyPackageJson(from.rootFsPath, installationName);
// If we can't find the package.json, it hasn't been installed.
if (dependencyPackageJsonPath === undefined) {
return undefined;
}
// We read the package.json file of the dependency.
const dependencyPackageJson = await readJsonFile(dependencyPackageJsonPath);
// We treat packages from within the monorepo a bit differently, so we
// check it here. All we do is using a different version to compute
// its input source name root.
const dependencyVersion = this.#isPackageJsonFromMonorepo(dependencyPackageJsonPath)
? "local"
: dependencyPackageJson.version;
// We get the input source name root of the dependency, to check if it
// already exists in the graph.
const inputSourceNameRoot = dependencyPackageJsonPath ===
path.join(this.#hardhatProjectPackage.rootFsPath, "package.json")
? HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT
: this.#npmPackageToInputSourceNameRoot(dependencyPackageJson.name, dependencyVersion);
// If it exists, we need to update the installation map, as it was missing
// there with this installation name, and we return it.
const existingDependencyNpmPackageBySourceName = this.#packageByInputSourceNameRoot.get(inputSourceNameRoot);
if (existingDependencyNpmPackageBySourceName !== undefined) {
const resultOfExistingPackage = {
package: existingDependencyNpmPackageBySourceName,
generatedRemapping: this.#generateNpmRemapping(from, installationName, existingDependencyNpmPackageBySourceName),
};
npmPackageDependenciesMap.set(installationName, resultOfExistingPackage);
return resultOfExistingPackage;
}
// Otherwise it's the first time we see this package, so we add it to the
// graph.
const newDependencyNpmPackage = {
name: dependencyPackageJson.name,
version: dependencyVersion,
rootFsPath: path.dirname(dependencyPackageJsonPath),
inputSourceNameRoot,
exports: dependencyPackageJson.exports,
};
this.#insertNewPackage(newDependencyNpmPackage);
const resultOfNewPackage = {
package: newDependencyNpmPackage,
generatedRemapping: this.#generateNpmRemapping(from, installationName, newDependencyNpmPackage),
};
// We also need to add it to the installation map, as a dependency of `from`.
npmPackageDependenciesMap.set(installationName, resultOfNewPackage);
return resultOfNewPackage;
}
async selectBestUserRemapping(from, directImport) {
let userRemappings = this.#userRemappingsPerPackage.get(from.package);
if (userRemappings === undefined) {
const readResult = await this.#readPackageRemappings(from.package);
if (!readResult.success) {
return { success: false, error: readResult.error };
}
userRemappings = readResult.value;
this.#userRemappingsPerPackage.set(from.package, userRemappings);
}
const bestUserRemappingIndex = selectBestRemapping(from.inputSourceName, directImport, userRemappings);
if (bestUserRemappingIndex === undefined) {
return { success: true, value: undefined };
}
const bestUserRemapping = userRemappings[bestUserRemappingIndex];
if (bestUserRemapping.type === UserRemappingType.LOCAL ||
bestUserRemapping.type === UserRemappingType.NPM) {
return { success: true, value: bestUserRemapping };
}
const result = await this.#resolveNpmUserRemapping(from.package, bestUserRemapping);
if (!result.success) {
return { success: false, error: [result.error] };
}
// We replace the unresolved user remapping with the resolved one
userRemappings[bestUserRemappingIndex] = result.value;
return { success: true, value: result.value };
}
async generateRemappingIntoNpmFile(fromNpmPackage, directImport, targetInputSourceName) {
const remappingsIntoFiles = this.#generatedRemappingsIntoNpmFiles.get(fromNpmPackage);
assertHardhatInvariant(remappingsIntoFiles !== undefined, "Map of generated remappings should exist");
const existing = remappingsIntoFiles.get(directImport);
if (existing !== undefined) {
assertHardhatInvariant(existing.target === targetInputSourceName, "Trying to generate different remappings for the same direct import into an npm file");
return existing;
}
const remapping = {
context: fromNpmPackage.inputSourceNameRoot + "/",
prefix: directImport,
target: targetInputSourceName,
};
remappingsIntoFiles.set(directImport, remapping);
return remapping;
}
toJSON() {
return {
hardhatProjectPackage: this.#hardhatProjectPackage,
packageByInputSourceNameRoot: Object.fromEntries(this.#packageByInputSourceNameRoot.entries()),
installationMap: Object.fromEntries(Array.from(this.#installationMap.entries()).map(([pkg, dependenciesMap]) => {
return [
pkg.inputSourceNameRoot,
Object.fromEntries(dependenciesMap.entries()),
];
})),
userRemappingsPerPackage: Object.fromEntries(Array.from(this.#userRemappingsPerPackage.entries()).map(([pkg, remappings]) => {
return [pkg.inputSourceNameRoot, remappings];
})),
generatedRemappingsIntoNpmFiles: Object.fromEntries(Array.from(this.#generatedRemappingsIntoNpmFiles.entries()).map(([pkg, remappings]) => {
return [pkg.inputSourceNameRoot, Object.fromEntries(remappings)];
})),
};
}
/**
* Inserts a new package into the maps and queues, maintaining the invariants
* of this class.
*
* @param npmPackage The package.
*/
#insertNewPackage(npmPackage) {
this.#installationMap.set(npmPackage, new Map());
this.#packageByInputSourceNameRoot.set(npmPackage.inputSourceNameRoot, npmPackage);
this.#generatedRemappingsIntoNpmFiles.set(npmPackage, new Map());
// Note: We intentionally don't add an empty array to the map of user
// remappings, so that we can easily check if they have been processed.
}
/**
* Reads all the user remappings of a package, validating their format and
* processing them, but without loading their npm packages (if any).
*
* @param npmPackage The package.
*/
async #readPackageRemappings(npmPackage) {
const remappingsTxtFiles = await getAllFilesMatching(npmPackage.rootFsPath, (f) => path.basename(f) === "remappings.txt", (f) => !f.endsWith("node_modules"));
const remappings = [];
const errors = [];
for (const remappingsTxtFsPath of remappingsTxtFiles) {
const packageRemappingsTxtContents = await readUtf8File(remappingsTxtFsPath);
const rawUserRemappings = packageRemappingsTxtContents
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "")
.filter((line) => !line.startsWith("#"));
for (const userRemapping of rawUserRemappings) {
const result = await this.#parseUserRemapping(npmPackage, remappingsTxtFsPath, userRemapping);
if (!result.success) {
errors.push(result.error);
}
else {
// If parsing returned `undefined`, it means that it should be
// ignored.
if (result.value === undefined) {
continue;
}
remappings.push(result.value);
}
}
}
if (errors.length > 0) {
return { success: false, error: errors };
}
return { success: true, value: remappings };
}
/**
* Parses a user remapping, validating it, and preprocessing it, but without
* loading its npm package (if any).
*
* @param npmPackage The npm package, which remapping is being resolved.
* @param sourceOfTheRemapping The source of the remapping.
* @param remappingString The remapping in raw format.
* @returns The parsed user remapping, or undefined if it should be ignored.
* If the parsing and validation fails, an error is returned.
*/
async #parseUserRemapping(npmPackage, sourceOfTheRemapping, remappingString) {
// We first parse the remapping string and validate that it doesn't have
// a context starting with `npm/`, and that the prefix and targets end in /.
const remapping = parseRemappingString(remappingString);
if (remapping === undefined) {
return {
success: false,
error: {
remapping: remappingString,
type: UserRemappingErrorType.REMAPPING_WITH_INVALID_SYNTAX,
source: sourceOfTheRemapping,
},
};
}
// Note: User remappings must have each of their components ending with `/`,
// except for the context. If they don't end with a slash, we add it.
const context = remapping.context;
const prefix = remapping.prefix.endsWith("/")
? remapping.prefix
: remapping.prefix + "/";
const target = remapping.target.endsWith("/")
? remapping.target
: remapping.target + "/";
const relativeFsPathToRemappingsFile = path.relative(npmPackage.rootFsPath, path.dirname(sourceOfTheRemapping));
// If the remapping's target starts with `node_modules/`, we treat
// it as trying to load an npm dependency, otherwise we treat it as a local
// remapping.
// Local remapping case
if (!target.startsWith("node_modules/")) {
return {
success: true,
value: {
type: UserRemappingType.LOCAL,
context: this.#updateRemappingsTxFragment(npmPackage, relativeFsPathToRemappingsFile, context),
prefix,
target: this.#updateRemappingsTxFragment(npmPackage, relativeFsPathToRemappingsFile, target),
originalFormat: remappingString,
source: sourceOfTheRemapping,
},
};
}
// If we are here the remapping is a npm remapping.
// We first remove the node_modules/ prefix from the actual target.
const targetWithoutNodeModules = target.substring("node_modules/".length);
// If after doing that the prefix and target are the same, we skip it
// so that it doesn't even go unnecesarly go through a user remapping.
if (prefix === targetWithoutNodeModules) {
return { success: true, value: undefined };
}
// If we are treating it as remapping into an npm package, it's syntax,
// after removing the `node_modules/` prefix, should be similar to
// an npm module's (i.e. `<package-name>/<file-path>`), except that
// `<file-path>` here could be a prefix, and not a file path.
//
// Note that that package name is the installation name of the dependency
// within the npm package, not the actual dependency name.
const installationName = getNpmPackageName(targetWithoutNodeModules);
if (installationName === undefined) {
return {
success: false,
error: {
type: UserRemappingErrorType.REMAPPING_WITH_INVALID_SYNTAX,
source: sourceOfTheRemapping,
remapping: remappingString,
},
};
}
return {
success: true,
value: {
type: "UNRESOLVED_NPM",
installationName,
context: this.#updateRemappingsTxFragment(npmPackage, relativeFsPathToRemappingsFile, context),
prefix,
target,
originalFormat: remappingString,
source: sourceOfTheRemapping,
},
};
}
async #resolveNpmUserRemapping(npmPackage, unresolvedNpmRemapping) {
const dependency = await this.resolveDependencyByInstallationName(npmPackage, unresolvedNpmRemapping.installationName);
// If we can't find the dependency, it hasn't been installed.
if (dependency === undefined) {
return {
success: false,
error: {
remapping: unresolvedNpmRemapping.originalFormat,
type: UserRemappingErrorType.REMAPPING_TO_UNINSTALLED_PACKAGE,
source: unresolvedNpmRemapping.source,
},
};
}
const target = dependency.package.inputSourceNameRoot +
unresolvedNpmRemapping.target.substring("node_modules/".length + unresolvedNpmRemapping.installationName.length);
return {
success: true,
value: {
type: UserRemappingType.NPM,
context: unresolvedNpmRemapping.context,
prefix: unresolvedNpmRemapping.prefix,
originalFormat: unresolvedNpmRemapping.originalFormat,
source: unresolvedNpmRemapping.source,
target,
targetNpmPackage: {
installationName: unresolvedNpmRemapping.installationName,
package: dependency.package,
},
},
};
}
/**
* Generates a remapping used to resolve an import from `from` to `to` using
* the installation name `installationName` as a prefix.
*/
#generateNpmRemapping(from, installationName, to) {
return {
context: from.inputSourceNameRoot + "/",
prefix: installationName + "/",
target: to.inputSourceNameRoot + "/",
};
}
#isPackageJsonFromMonorepo(packageJsonFsPath) {
return (!packageJsonFsPath.includes("node_modules") &&
!packageJsonFsPath.startsWith(this.#hardhatProjectPackage.rootFsPath + path.sep));
}
#npmPackageToInputSourceNameRoot(name, version) {
return `npm/${name}@${version}`;
}
/**
* Updates a fragment of a remapping found in a remappings.txt in the package
* from.
*
* This is used to update both contexts and targets.
*
* This function doesn't update any fragment starting with npm/
*/
#updateRemappingsTxFragment(from, relativeFsPathToRemappingsFileFromPackage, remappingFragment) {
if (remappingFragment.startsWith("npm/")) {
return remappingFragment;
}
return sourceNamePathJoin(
// We add a slash here so that it mains it if the rest of the path is empty
from.inputSourceNameRoot + "/",
// Same here
relativeFsPathToRemappingsFileFromPackage + "/", remappingFragment);
}
}
//# sourceMappingURL=remapped-npm-packages-graph.js.map