diff --git a/.changeset/olive-shirts-obey.md b/.changeset/olive-shirts-obey.md new file mode 100644 index 00000000000..5e8db02bb52 --- /dev/null +++ b/.changeset/olive-shirts-obey.md @@ -0,0 +1,5 @@ +--- +"hardhat": patch +--- + +Suggest installing hardhat-foundry when appropriate diff --git a/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/dependency-resolver.ts b/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/dependency-resolver.ts index 4d072283ee1..63bf3be28ea 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/dependency-resolver.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/dependency-resolver.ts @@ -696,6 +696,13 @@ export class ResolverImplementation implements Resolver { ); if (dependencyResolution === undefined) { + // Check if the from file's package has foundry.toml + const foundryTomlPath = path.join( + from.package.rootFsPath, + "foundry.toml", + ); + const fromHasFoundryToml = await exists(foundryTomlPath); + return { success: false, error: { @@ -703,6 +710,7 @@ export class ResolverImplementation implements Resolver { fromFsPath: from.fsPath, importPath, installationName: parsedDirectImport.package, + importerPackageHasFoundryToml: fromHasFoundryToml, }, }; } @@ -1103,6 +1111,7 @@ export class ResolverImplementation implements Resolver { type: RootResolutionErrorType.NPM_ROOT_FILE_OF_UNINSTALLED_PACKAGE, npmModule, installationName: error.installationName, + projectHasFoundryToml: error.importerPackageHasFoundryToml, }; } case ImportResolutionErrorType.IMPORT_WITH_REMAPPING_ERRORS: { diff --git a/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/error-messages.ts b/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/error-messages.ts index 75b42a89544..792bc83e3fb 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/error-messages.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/error-messages.ts @@ -57,7 +57,14 @@ Note that the npm module is being remapped by ${formatRemappingReference(error.u } case RootResolutionErrorType.NPM_ROOT_FILE_OF_UNINSTALLED_PACKAGE: { - return `The package "${error.installationName}" is not installed.`; + const baseMessage = `The package "${error.installationName}" is not installed.`; + if (error.projectHasFoundryToml === true) { + return `${baseMessage} + +Your project has a foundry.toml, and you may need to install the "@nomicfoundation/hardhat-foundry" plugin. +Learn more about Hardhat's Foundry compatibility here: https://hardhat.org/foundry-compatibility`; + } + return baseMessage; } case RootResolutionErrorType.NPM_ROOT_FILE_RESOLUTION_WITH_REMAPPING_ERRORS: { @@ -107,13 +114,13 @@ export function formatImportResolutionError( } case ImportResolutionErrorType.ILLEGAL_RELATIVE_IMPORT: { - return "The import has too many '../', and trying to leave its package."; + return "The import has too many '../' and is trying to leave its package."; } case ImportResolutionErrorType.RELATIVE_IMPORT_INTO_NODE_MODULES: { return `You are trying to import a file from your node_modules directory with its file system path. -You should write your the path of your imports into npm modules just as you would do in JavaScript files.`; +You should write your imports into npm modules just as you would do in JavaScript files.`; } case ImportResolutionErrorType.IMPORT_WITH_INVALID_NPM_SYNTAX: { @@ -121,7 +128,14 @@ You should write your the path of your imports into npm modules just as you woul } case ImportResolutionErrorType.IMPORT_OF_UNINSTALLED_PACKAGE: { - return `The package "${error.installationName}" is not installed.`; + const baseMessage = `The package "${error.installationName}" is not installed.`; + if (error.importerPackageHasFoundryToml === true) { + return `${baseMessage} + +The file importing this package is inside a Foundry project (foundry.toml detected), and you may need to install the "@nomicfoundation/hardhat-foundry" plugin. +Learn more about Hardhat's Foundry compatibility here: https://hardhat.org/foundry-compatibility`; + } + return baseMessage; } case ImportResolutionErrorType.IMPORT_WITH_REMAPPING_ERRORS: { @@ -148,7 +162,7 @@ If you still want to be able to do it, try adding this remapping "${error.sugges ? "the package" : "the project"; - const message = `The file ${error.packageExportsResolvedSubpath ?? error.subpath} doesn't exist within ${packageOrProject}.`; + const message = `The file "${error.packageExportsResolvedSubpath ?? error.subpath}" doesn't exist within ${packageOrProject}.`; return formatResolutionErrorRemappingsOrPackageExportsNotes({ message, diff --git a/v-next/hardhat/src/types/solidity/errors.ts b/v-next/hardhat/src/types/solidity/errors.ts index 855e0c4327e..6cccde7e481 100644 --- a/v-next/hardhat/src/types/solidity/errors.ts +++ b/v-next/hardhat/src/types/solidity/errors.ts @@ -91,6 +91,11 @@ export interface NpmRootFileOfUninstalledPackageError { type: RootResolutionErrorType.NPM_ROOT_FILE_OF_UNINSTALLED_PACKAGE; npmModule: string; installationName: string; + /** + * A boolean indicating if the Hardhat project has a foundry.toml file in its + * root. + */ + projectHasFoundryToml?: boolean; } export interface NpmRootResolutionWithRemappingErrors { @@ -109,6 +114,10 @@ export interface NpmRootFileWithIncorrectCasingError extends ResolvedFileReference { type: RootResolutionErrorType.NPM_ROOT_FILE_WITH_INCORRECT_CASING; npmModule: string; + /** + * The correct casing of the file, expressed as the relative fs path from the + * package's root, using forward slashes, and without a leading "./". + */ correctCasing: string; } @@ -216,6 +225,10 @@ export interface ImportInvalidCasingError extends ResolvedFileReference { type: ImportResolutionErrorType.IMPORT_INVALID_CASING; fromFsPath: string; importPath: string; + /** + * The correct casing of the file, expressed as the relative fs path from the + * package's root, using forward slashes, and without a leading "./". + */ correctCasing: string; } @@ -230,6 +243,11 @@ export interface ImportOfUninstalledPackageError { fromFsPath: string; importPath: string; installationName: string; + /** + * A boolean indicating weather the import is from a source file whose package + * has a foundry.toml file in its root. + */ + importerPackageHasFoundryToml?: boolean; } export interface ImportWithRemappingErrorsError { @@ -348,10 +366,14 @@ export interface ResolvedFileReference { // package's input source name root, after applying the user remapping. // - For an npm import, it's the npm-subpath. i.e. the module identifier // without the package name. + // + // This never includes a leading "./". subpath: string; // The subpath after resolving package.exports // // Only present when actually using package.exports. + // + // This never includes a leading "./". packageExportsResolvedSubpath?: string; } diff --git a/v-next/hardhat/test/internal/builtin-plugins/solidity/build-system/resolver/dependency-resolver.ts b/v-next/hardhat/test/internal/builtin-plugins/solidity/build-system/resolver/dependency-resolver.ts index 3579e6166aa..5e8652b544f 100644 --- a/v-next/hardhat/test/internal/builtin-plugins/solidity/build-system/resolver/dependency-resolver.ts +++ b/v-next/hardhat/test/internal/builtin-plugins/solidity/build-system/resolver/dependency-resolver.ts @@ -320,6 +320,40 @@ other-exports/=node_modules/exports/other/`, type: RootResolutionErrorType.NPM_ROOT_FILE_OF_UNINSTALLED_PACKAGE, npmModule, installationName: "not-installed", + projectHasFoundryToml: false, + }; + + assert.deepEqual( + await resolver.resolveNpmDependencyFileAsRoot(npmModule), + { + success: false, + error: expectedError, + }, + ); + }); + + it("Should set projectHasFoundryToml to true when project has foundry.toml", async () => { + const templateWithFoundry: TestProjectTemplate = { + name: "foundry-project-npm-root", + version: "1.0.0", + files: { + "foundry.toml": "[profile.default]\nsrc = 'contracts'\n", + "contracts/A.sol": `A`, + }, + }; + + await using project = await useTestProjectTemplate(templateWithFoundry); + const resolver = await ResolverImplementation.create( + project.path, + readUtf8File, + ); + + const npmModule = "forge-std/Test.sol"; + const expectedError: NpmRootResolutionError = { + type: RootResolutionErrorType.NPM_ROOT_FILE_OF_UNINSTALLED_PACKAGE, + npmModule, + installationName: "forge-std", + projectHasFoundryToml: true, }; assert.deepEqual( @@ -2127,11 +2161,173 @@ submodule2/=lib/submodule2/src/`, fromFsPath: absoluteFilePath, importPath, installationName: "not-installed", + importerPackageHasFoundryToml: false, }, }, ); }); + describe("foundry.toml detection for uninstalled packages", () => { + it("Should set importerPackageHasFoundryToml to true when project has foundry.toml", async () => { + const templateWithFoundry: TestProjectTemplate = { + name: "foundry-project", + version: "1.0.0", + files: { + "foundry.toml": "[profile.default]\nsrc = 'contracts'\n", + "contracts/A.sol": `A`, + }, + }; + + await using project = + await useTestProjectTemplate(templateWithFoundry); + const resolver = await ResolverImplementation.create( + project.path, + readUtf8File, + ); + const absoluteFilePath = path.join(project.path, "contracts/A.sol"); + const result = await resolver.resolveProjectFile(absoluteFilePath); + assert.ok(result.success, "Result should be successful"); + + const importPath = "forge-std/Test.sol"; + assert.deepEqual( + await resolver.resolveImport(result.value, importPath), + { + success: false, + error: { + type: ImportResolutionErrorType.IMPORT_OF_UNINSTALLED_PACKAGE, + fromFsPath: absoluteFilePath, + importPath, + installationName: "forge-std", + importerPackageHasFoundryToml: true, + }, + }, + ); + }); + + it("Should set importerPackageHasFoundryToml to true when npm dependency has foundry.toml", async () => { + const templateWithNpmFoundry: TestProjectTemplate = { + name: "project-with-foundry-dep", + version: "1.0.0", + files: { + "contracts/A.sol": `A`, + }, + dependencies: { + "my-foundry-dep": { + name: "my-foundry-dep", + version: "1.0.0", + files: { + "foundry.toml": "[profile.default]\nsrc = 'src'\n", + "src/Lib.sol": `import "forge-std/Test.sol";`, + }, + }, + }, + }; + + await using project = await useTestProjectTemplate( + templateWithNpmFoundry, + ); + const resolver = await ResolverImplementation.create( + project.path, + readUtf8File, + ); + + // First, resolve a project file that imports from the npm dependency + const absoluteFilePath = path.join(project.path, "contracts/A.sol"); + const projectResult = + await resolver.resolveProjectFile(absoluteFilePath); + assert.ok(projectResult.success, "Project file should resolve"); + + // Resolve the npm dependency file + const npmDepResult = await resolver.resolveImport( + projectResult.value, + "my-foundry-dep/src/Lib.sol", + ); + assert.ok(npmDepResult.success, "Npm dependency should resolve"); + + // Now try to import an uninstalled package from the npm dependency + const importPath = "forge-std/Test.sol"; + const errorResult = await resolver.resolveImport( + npmDepResult.value.file, + importPath, + ); + + assert.deepEqual(errorResult, { + success: false, + error: { + type: ImportResolutionErrorType.IMPORT_OF_UNINSTALLED_PACKAGE, + fromFsPath: path.join( + project.path, + "node_modules/my-foundry-dep/src/Lib.sol", + ), + importPath, + installationName: "forge-std", + importerPackageHasFoundryToml: true, + }, + }); + }); + + it("Should set importerPackageHasFoundryToml to false when npm dependency does not have foundry.toml", async () => { + const templateWithoutFoundry: TestProjectTemplate = { + name: "project-without-foundry-dep", + version: "1.0.0", + files: { + "contracts/A.sol": `A`, + }, + dependencies: { + "regular-dep": { + name: "regular-dep", + version: "1.0.0", + files: { + "src/Lib.sol": `import "some-lib/Foo.sol";`, + }, + }, + }, + }; + + await using project = await useTestProjectTemplate( + templateWithoutFoundry, + ); + const resolver = await ResolverImplementation.create( + project.path, + readUtf8File, + ); + + // First, resolve a project file that imports from the npm dependency + const absoluteFilePath = path.join(project.path, "contracts/A.sol"); + const projectResult = + await resolver.resolveProjectFile(absoluteFilePath); + assert.ok(projectResult.success, "Project file should resolve"); + + // Resolve the npm dependency file + const npmDepResult = await resolver.resolveImport( + projectResult.value, + "regular-dep/src/Lib.sol", + ); + assert.ok(npmDepResult.success, "Npm dependency should resolve"); + + // Now try to import an uninstalled package from the npm dependency + const importPath = "some-lib/Foo.sol"; + const errorResult = await resolver.resolveImport( + npmDepResult.value.file, + importPath, + ); + + assert.deepEqual(errorResult, { + success: false, + error: { + type: ImportResolutionErrorType.IMPORT_OF_UNINSTALLED_PACKAGE, + fromFsPath: path.join( + project.path, + "node_modules/regular-dep/src/Lib.sol", + ), + importPath, + installationName: "some-lib", + importerPackageHasFoundryToml: false, + }, + }); + }); + }); + describe("Without package.exports", () => { it("Should fail if the file doesn't exist within the package", async () => { await using project = await useTestProjectTemplate(template); diff --git a/v-next/hardhat/test/internal/builtin-plugins/solidity/build-system/resolver/error-messages.ts b/v-next/hardhat/test/internal/builtin-plugins/solidity/build-system/resolver/error-messages.ts new file mode 100644 index 00000000000..fa6506905ac --- /dev/null +++ b/v-next/hardhat/test/internal/builtin-plugins/solidity/build-system/resolver/error-messages.ts @@ -0,0 +1,731 @@ +import type { + NpmPackageReference, + UserRemappingReference, +} from "../../../../../../src/types/solidity/errors.js"; + +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { + formatImportResolutionError, + formatNpmRootResolutionError, + formatProjectRootResolutionError, +} from "../../../../../../src/internal/builtin-plugins/solidity/build-system/resolver/error-messages.js"; +import { + ImportResolutionErrorType, + RootResolutionErrorType, + UserRemappingErrorType, +} from "../../../../../../src/types/solidity/errors.js"; +import { ResolvedFileType } from "../../../../../../src/types/solidity.js"; + +function joinPathWithPrefix(...segments: string[]): string { + return "." + path.sep + path.join(...segments); +} + +function makeRemapping( + overrides: Partial = {}, +): UserRemappingReference { + return { + originalUserRemapping: overrides.originalUserRemapping ?? "foo=bar", + actualUserRemapping: overrides.actualUserRemapping ?? "foo=bar", + remappingSource: + overrides.remappingSource ?? path.join(process.cwd(), "remappings.txt"), + }; +} + +function makeNpmPackage( + overrides: Partial = {}, +): NpmPackageReference { + return { + name: overrides.name ?? "@openzeppelin/contracts", + version: overrides.version ?? "5.0.0", + rootFsPath: + overrides.rootFsPath ?? + path.join(process.cwd(), "node_modules/@openzeppelin/contracts"), + }; +} + +describe("Error messages", () => { + describe("formatProjectRootResolutionError", () => { + describe("PROJECT_ROOT_FILE_NOT_IN_PROJECT", () => { + it("Should return a message about the file not being in the project", () => { + const result = formatProjectRootResolutionError({ + type: RootResolutionErrorType.PROJECT_ROOT_FILE_NOT_IN_PROJECT, + absoluteFilePath: "/outside/Token.sol", + }); + + assert.equal(result, "The file is not inside your Hardhat project."); + }); + }); + + describe("PROJECT_ROOT_FILE_DOES_NOT_EXIST", () => { + it("Should return a message about the file not existing", () => { + const result = formatProjectRootResolutionError({ + type: RootResolutionErrorType.PROJECT_ROOT_FILE_DOES_NOT_EXIST, + absoluteFilePath: "/missing/Token.sol", + }); + + assert.equal(result, "The file doesn't exist"); + }); + }); + + describe("PROJECT_ROOT_FILE_IN_NODE_MODULES", () => { + it("Should return a message about node_modules and docs reference", () => { + const result = formatProjectRootResolutionError({ + type: RootResolutionErrorType.PROJECT_ROOT_FILE_IN_NODE_MODULES, + absoluteFilePath: "/project/node_modules/foo/Bar.sol", + }); + + assert.equal( + result, + `The file is inside your node_modules directory. + +Please read Hardhat's documentation to learn how to compile npm files.`, + ); + }); + }); + }); + + describe("formatNpmRootResolutionError", () => { + describe("NPM_ROOT_FILE_NAME_WITH_INVALID_FORMAT", () => { + it("Should return a message about invalid npm module syntax", () => { + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_NAME_WITH_INVALID_FORMAT, + npmModule: "???invalid", + }); + + assert.equal(result, "The npm module syntax is invalid"); + }); + }); + + describe("NPM_ROOT_FILE_RESOLVES_TO_PROJECT_FILE", () => { + it("Should return the shortened path when there is no user remapping", () => { + const resolvedPath = path.join(process.cwd(), "contracts", "Token.sol"); + + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_RESOLVES_TO_PROJECT_FILE, + npmModule: "contracts/Token.sol", + resolvedFileFsPath: resolvedPath, + }); + + assert.equal( + result, + `The npm module resolves to the local file "${joinPathWithPrefix("contracts", "Token.sol")}".`, + ); + }); + + it("Should include remapping note when userRemapping is defined", () => { + const resolvedPath = path.join(process.cwd(), "contracts", "Token.sol"); + const remapping = makeRemapping({ + originalUserRemapping: "oz=contracts", + }); + + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_RESOLVES_TO_PROJECT_FILE, + npmModule: "oz/Token.sol", + resolvedFileFsPath: resolvedPath, + userRemapping: remapping, + }); + + assert.equal( + result, + `The npm module resolves to the local file "${joinPathWithPrefix("contracts", "Token.sol")}". + +Note that the npm module is being remapped by "oz=contracts" from "${joinPathWithPrefix("remappings.txt")}".`, + ); + }); + }); + + describe("NPM_ROOT_FILE_OF_UNINSTALLED_PACKAGE", () => { + it("Should return base message when projectHasFoundryToml is undefined", () => { + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_OF_UNINSTALLED_PACKAGE, + npmModule: "missing-pkg/Token.sol", + installationName: "missing-pkg", + }); + + assert.equal(result, 'The package "missing-pkg" is not installed.'); + }); + + it("Should return base message when projectHasFoundryToml is false", () => { + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_OF_UNINSTALLED_PACKAGE, + npmModule: "missing-pkg/Token.sol", + installationName: "missing-pkg", + projectHasFoundryToml: false, + }); + + assert.equal(result, 'The package "missing-pkg" is not installed.'); + }); + + it("Should include foundry suggestion when projectHasFoundryToml is true", () => { + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_OF_UNINSTALLED_PACKAGE, + npmModule: "missing-pkg/Token.sol", + installationName: "missing-pkg", + projectHasFoundryToml: true, + }); + + assert.equal( + result, + `The package "missing-pkg" is not installed. + +Your project has a foundry.toml, and you may need to install the "@nomicfoundation/hardhat-foundry" plugin. +Learn more about Hardhat's Foundry compatibility here: https://hardhat.org/foundry-compatibility`, + ); + }); + }); + + describe("NPM_ROOT_FILE_RESOLUTION_WITH_REMAPPING_ERRORS", () => { + it("Should format a single REMAPPING_WITH_INVALID_SYNTAX error", () => { + const source = path.join(process.cwd(), "remappings.txt"); + + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_RESOLUTION_WITH_REMAPPING_ERRORS, + npmModule: "foo/Token.sol", + remappingErrors: [ + { + type: UserRemappingErrorType.REMAPPING_WITH_INVALID_SYNTAX, + remapping: "bad-remapping", + source, + }, + ], + }); + + assert.equal( + result, + `These remapping errors were found while trying to resolve it: + + - "bad-remapping" from "${joinPathWithPrefix("remappings.txt")}": Invalid syntax.`, + ); + }); + + it("Should format a single REMAPPING_TO_UNINSTALLED_PACKAGE error", () => { + const source = path.join(process.cwd(), "remappings.txt"); + + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_RESOLUTION_WITH_REMAPPING_ERRORS, + npmModule: "foo/Token.sol", + remappingErrors: [ + { + type: UserRemappingErrorType.REMAPPING_TO_UNINSTALLED_PACKAGE, + remapping: "foo=uninstalled-pkg", + source, + }, + ], + }); + + assert.equal( + result, + `These remapping errors were found while trying to resolve it: + + - "foo=uninstalled-pkg" from "${joinPathWithPrefix("remappings.txt")}": The npm package from its target is not installed.`, + ); + }); + + it("Should format multiple errors with ' - ' prefix each", () => { + const source = path.join(process.cwd(), "remappings.txt"); + + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_RESOLUTION_WITH_REMAPPING_ERRORS, + npmModule: "foo/Token.sol", + remappingErrors: [ + { + type: UserRemappingErrorType.REMAPPING_WITH_INVALID_SYNTAX, + remapping: "bad-syntax", + source, + }, + { + type: UserRemappingErrorType.REMAPPING_TO_UNINSTALLED_PACKAGE, + remapping: "foo=missing", + source, + }, + ], + }); + + assert.equal( + result, + `These remapping errors were found while trying to resolve it: + + - "bad-syntax" from "${joinPathWithPrefix("remappings.txt")}": Invalid syntax. + - "foo=missing" from "${joinPathWithPrefix("remappings.txt")}": The npm package from its target is not installed.`, + ); + }); + }); + + describe("NPM_ROOT_FILE_DOES_NOT_EXIST_WITHIN_ITS_PACKAGE", () => { + it("Should use packageExportsResolvedSubpath and add exports note when present", () => { + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_DOES_NOT_EXIST_WITHIN_ITS_PACKAGE, + npmModule: "@oz/contracts/Token.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "Token.sol", + packageExportsResolvedSubpath: "src/Token.sol", + }); + + assert.equal( + result, + `The file "src/Token.sol" doesn't exist within the package. + +Note that the file was referred to as "Token.sol" but the package's package.exports redirects it to "src/Token.sol".`, + ); + }); + + it("Should include remapping note when userRemapping is defined and no exports", () => { + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_DOES_NOT_EXIST_WITHIN_ITS_PACKAGE, + npmModule: "@oz/contracts/Token.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "Token.sol", + userRemapping: makeRemapping(), + }); + + assert.equal( + result, + `The file "Token.sol" doesn't exist within the package. + +Note that the npm module is being remapped by "foo=bar" from "${joinPathWithPrefix("remappings.txt")}".`, + ); + }); + + it("Should return plain message when no exports and no remapping", () => { + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_DOES_NOT_EXIST_WITHIN_ITS_PACKAGE, + npmModule: "@oz/contracts/Token.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "Token.sol", + }); + + assert.equal( + result, + 'The file "Token.sol" doesn\'t exist within the package.', + ); + }); + }); + + describe("NPM_ROOT_FILE_WITH_INCORRECT_CASING", () => { + it("Should use packageExportsResolvedSubpath and add exports note when present", () => { + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_WITH_INCORRECT_CASING, + npmModule: "@oz/contracts/token.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "token.sol", + packageExportsResolvedSubpath: "src/token.sol", + correctCasing: "src/Token.sol", + }); + + assert.equal( + result, + `The file "src/token.sol" casing is wrong. It should be "src/Token.sol". + +Note that the file was referred to as "token.sol" but the package's package.exports redirects it to "src/token.sol".`, + ); + }); + + it("Should return plain casing message when no extras", () => { + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_WITH_INCORRECT_CASING, + npmModule: "@oz/contracts/token.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "token.sol", + correctCasing: "Token.sol", + }); + + assert.equal( + result, + 'The file "token.sol" casing is wrong. It should be "Token.sol".', + ); + }); + }); + + describe("NPM_ROOT_FILE_NON_EXPORTED_FILE", () => { + it("Should return a plain non-exported message", () => { + const result = formatNpmRootResolutionError({ + type: RootResolutionErrorType.NPM_ROOT_FILE_NON_EXPORTED_FILE, + npmModule: "@oz/contracts/internal.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "internal.sol", + }); + + assert.equal( + result, + 'The file "internal.sol" is not exported by the package.', + ); + }); + }); + }); + + describe("formatImportResolutionError", () => { + describe("IMPORT_WITH_WINDOWS_PATH_SEPARATORS", () => { + it("Should return a message about windows path separators", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_WITH_WINDOWS_PATH_SEPARATORS, + fromFsPath: "/project/A.sol", + importPath: ".\\B.sol", + }); + + assert.equal( + result, + "The import contains windows path separators. Please use forward slashes instead.", + ); + }); + }); + + describe("ILLEGAL_RELATIVE_IMPORT", () => { + it("Should return a message about too many '../'", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.ILLEGAL_RELATIVE_IMPORT, + fromFsPath: "/project/A.sol", + importPath: "../../../../outside.sol", + }); + + assert.equal( + result, + "The import has too many '../' and is trying to leave its package.", + ); + }); + }); + + describe("RELATIVE_IMPORT_INTO_NODE_MODULES", () => { + it("Should return a message about importing node_modules with filesystem path", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.RELATIVE_IMPORT_INTO_NODE_MODULES, + fromFsPath: "/project/A.sol", + importPath: "../node_modules/foo/Bar.sol", + }); + + assert.equal( + result, + `You are trying to import a file from your node_modules directory with its file system path. + +You should write your imports into npm modules just as you would do in JavaScript files.`, + ); + }); + }); + + describe("IMPORT_WITH_INVALID_NPM_SYNTAX", () => { + it("Should return a message about invalid npm syntax", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_WITH_INVALID_NPM_SYNTAX, + fromFsPath: "/project/A.sol", + importPath: "???invalid", + }); + + assert.equal( + result, + "You are trying to import an npm file but its syntax is invalid.", + ); + }); + }); + + describe("IMPORT_OF_UNINSTALLED_PACKAGE", () => { + it("Should return base message when importerPackageHasFoundryToml is undefined", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_OF_UNINSTALLED_PACKAGE, + fromFsPath: "/project/A.sol", + importPath: "missing-pkg/Token.sol", + installationName: "missing-pkg", + }); + + assert.equal(result, 'The package "missing-pkg" is not installed.'); + }); + + it("Should return base message when importerPackageHasFoundryToml is false", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_OF_UNINSTALLED_PACKAGE, + fromFsPath: "/project/A.sol", + importPath: "missing-pkg/Token.sol", + installationName: "missing-pkg", + importerPackageHasFoundryToml: false, + }); + + assert.equal(result, 'The package "missing-pkg" is not installed.'); + }); + + it("Should include foundry note when importerPackageHasFoundryToml is true", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_OF_UNINSTALLED_PACKAGE, + fromFsPath: "/project/A.sol", + importPath: "missing-pkg/Token.sol", + installationName: "missing-pkg", + importerPackageHasFoundryToml: true, + }); + + assert.equal( + result, + `The package "missing-pkg" is not installed. + +The file importing this package is inside a Foundry project (foundry.toml detected), and you may need to install the "@nomicfoundation/hardhat-foundry" plugin. +Learn more about Hardhat's Foundry compatibility here: https://hardhat.org/foundry-compatibility`, + ); + }); + }); + + describe("IMPORT_WITH_REMAPPING_ERRORS", () => { + it("Should format multiple remapping errors", () => { + const source = path.join(process.cwd(), "remappings.txt"); + + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_WITH_REMAPPING_ERRORS, + fromFsPath: "/project/A.sol", + importPath: "foo/Token.sol", + remappingErrors: [ + { + type: UserRemappingErrorType.REMAPPING_WITH_INVALID_SYNTAX, + remapping: "bad-syntax", + source, + }, + { + type: UserRemappingErrorType.REMAPPING_TO_UNINSTALLED_PACKAGE, + remapping: "foo=missing", + source, + }, + ], + }); + + assert.equal( + result, + `These remapping errors were found while trying to resolve the import: + + - "bad-syntax" from "${joinPathWithPrefix("remappings.txt")}": Invalid syntax. + - "foo=missing" from "${joinPathWithPrefix("remappings.txt")}": The npm package from its target is not installed.`, + ); + }); + }); + + describe("RELATIVE_IMPORT_CLASHES_WITH_USER_REMAPPING", () => { + it("Should include the direct import and remapping reference", () => { + const remapping = makeRemapping({ + originalUserRemapping: "contracts/=src/", + }); + + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.RELATIVE_IMPORT_CLASHES_WITH_USER_REMAPPING, + fromFsPath: "/project/A.sol", + importPath: "./contracts/Token.sol", + directImport: "contracts/Token.sol", + userRemapping: remapping, + }); + + assert.equal( + result, + `The relative import you are writing gets resolved to "contracts/Token.sol" which clashes with the remapping "contracts/=src/" from "${joinPathWithPrefix("remappings.txt")}", and this is not allowed by Hardhat. + +If you want to use the remapping, write your import as "contracts/Token.sol" instead.`, + ); + }); + }); + + describe("DIRECT_IMPORT_TO_LOCAL_FILE", () => { + it("Should include suggested remapping and remappings.txt", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.DIRECT_IMPORT_TO_LOCAL_FILE, + fromFsPath: "/project/A.sol", + importPath: "contracts/Token.sol", + suggestedRemapping: "contracts/=contracts/", + }); + + assert.equal( + result, + `You are trying to import a local file with a direct import path instead of a relative one, and this is not allowed by Hardhat. + +If you still want to be able to do it, try adding this remapping "contracts/=contracts/" to the "remappings.txt" file in the root of your project.`, + ); + }); + }); + + describe("IMPORT_DOES_NOT_EXIST", () => { + it("Should say 'the package' for NPM_PACKAGE_FILE with no extras", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_DOES_NOT_EXIST, + fromFsPath: "/project/A.sol", + importPath: "@oz/contracts/Missing.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "Missing.sol", + }); + + assert.equal( + result, + `The file "Missing.sol" doesn't exist within the package.`, + ); + }); + + it("Should say 'the project' for PROJECT_FILE with no extras", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_DOES_NOT_EXIST, + fromFsPath: "/project/A.sol", + importPath: "./Missing.sol", + resolvedFileType: ResolvedFileType.PROJECT_FILE, + npmPackage: makeNpmPackage(), + subpath: "Missing.sol", + }); + + assert.equal( + result, + `The file "Missing.sol" doesn't exist within the project.`, + ); + }); + + it("Should include exports redirection note when packageExportsResolvedSubpath is present", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_DOES_NOT_EXIST, + fromFsPath: "/project/A.sol", + importPath: "@oz/contracts/Token.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "Token.sol", + packageExportsResolvedSubpath: "src/Token.sol", + }); + + assert.equal( + result, + `The file "src/Token.sol" doesn't exist within the package. + +Note that the file was referred to as "Token.sol" but the package's package.exports redirects it to "src/Token.sol".`, + ); + }); + + it("Should include remapping note with 'the import' when userRemapping is defined", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_DOES_NOT_EXIST, + fromFsPath: "/project/A.sol", + importPath: "@oz/contracts/Token.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "Token.sol", + userRemapping: makeRemapping(), + }); + + assert.equal( + result, + `The file "Token.sol" doesn't exist within the package. + +Note that the import is being remapped by "foo=bar" from "${joinPathWithPrefix("remappings.txt")}".`, + ); + }); + }); + + describe("IMPORT_INVALID_CASING", () => { + it("Should return plain casing message with no extras", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_INVALID_CASING, + fromFsPath: "/project/A.sol", + importPath: "@oz/contracts/token.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "token.sol", + correctCasing: "Token.sol", + }); + + assert.equal( + result, + 'The file "token.sol" casing is wrong. It should be "Token.sol".', + ); + }); + + it("Should include exports note when packageExportsResolvedSubpath is present", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_INVALID_CASING, + fromFsPath: "/project/A.sol", + importPath: "@oz/contracts/token.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "token.sol", + packageExportsResolvedSubpath: "src/token.sol", + correctCasing: "src/Token.sol", + }); + + assert.equal( + result, + `The file "src/token.sol" casing is wrong. It should be "src/Token.sol". + +Note that the file was referred to as "token.sol" but the package's package.exports redirects it to "src/token.sol".`, + ); + }); + + it("Should include remapping note with 'the import' when userRemapping is defined", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_INVALID_CASING, + fromFsPath: "/project/A.sol", + importPath: "@oz/contracts/token.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "token.sol", + correctCasing: "Token.sol", + userRemapping: makeRemapping(), + }); + + assert.equal( + result, + `The file "token.sol" casing is wrong. It should be "Token.sol". + +Note that the import is being remapped by "foo=bar" from "${joinPathWithPrefix("remappings.txt")}".`, + ); + }); + }); + + describe("IMPORT_OF_NON_EXPORTED_NPM_FILE", () => { + it("Should return plain non-exported message with no extras", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_OF_NON_EXPORTED_NPM_FILE, + fromFsPath: "/project/A.sol", + importPath: "@oz/contracts/internal.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "internal.sol", + }); + + assert.equal( + result, + 'The file "internal.sol" is not exported by the package.', + ); + }); + + it("Should include exports note when packageExportsResolvedSubpath is present", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_OF_NON_EXPORTED_NPM_FILE, + fromFsPath: "/project/A.sol", + importPath: "@oz/contracts/internal.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "internal.sol", + packageExportsResolvedSubpath: "src/internal.sol", + }); + + assert.equal( + result, + `The file "internal.sol" is not exported by the package. + +Note that the file was referred to as "internal.sol" but the package's package.exports redirects it to "src/internal.sol".`, + ); + }); + + it("Should include remapping note with 'the import' when userRemapping is defined", () => { + const result = formatImportResolutionError({ + type: ImportResolutionErrorType.IMPORT_OF_NON_EXPORTED_NPM_FILE, + fromFsPath: "/project/A.sol", + importPath: "@oz/contracts/internal.sol", + resolvedFileType: ResolvedFileType.NPM_PACKAGE_FILE, + npmPackage: makeNpmPackage(), + subpath: "internal.sol", + userRemapping: makeRemapping(), + }); + + assert.equal( + result, + `The file "internal.sol" is not exported by the package. + +Note that the import is being remapped by "foo=bar" from "${joinPathWithPrefix("remappings.txt")}".`, + ); + }); + }); + }); +});