diff --git a/apps/remix-ide-e2e/src/tests/importResolver.test.ts b/apps/remix-ide-e2e/src/tests/importResolver.test.ts new file mode 100644 index 00000000000..9992a792800 --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/importResolver.test.ts @@ -0,0 +1,380 @@ +'use strict' +import { NightwatchBrowser } from 'nightwatch' +import init from '../helpers/init' + +module.exports = { + '@disabled': true, // Set to true to disable this test suite + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + '@sources': function () { + return sources + }, + + 'Test NPM Import with Versioned Folders #group1': function (browser: NightwatchBrowser) { + browser + .clickLaunchIcon('filePanel') + .click('li[data-id="treeViewLitreeViewItemREADME.txt"') + .addFile('UpgradeableNFT.sol', sources[0]['UpgradeableNFT.sol']) + .pause(3000) + .waitForElementVisible('*[data-id="treeViewDivDraggableItem.deps"]', 120000) + .click('*[data-id="treeViewDivDraggableItem.deps"]') + .waitForElementVisible('*[data-id="treeViewDivDraggableItem.deps/npm"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm"]') + .waitForElementVisible('*[data-id="treeViewDivDraggableItem.deps/npm/@openzeppelin"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm/@openzeppelin"]') + // Verify versioned folder naming: contracts-upgradeable@VERSION + .waitForElementPresent('*[data-id^="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts-upgradeable@"]', 60000) + }, + + 'Verify package.json in versioned folder #group1': function (browser: NightwatchBrowser) { + browser + .click('*[data-id^="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts-upgradeable@"]') + .waitForElementVisible('*[data-id$="/package.json"]', 120000) + .pause(1000) + .perform(function () { + // Open the package.json (we need to get the exact selector dynamically) + browser.elements('css selector', '*[data-id$="/package.json"]', function (result) { + if (result.value && Array.isArray(result.value) && result.value.length > 0) { + const selector = '*[data-id$="/package.json"]' + browser.click(selector) + } + }) + }) + .pause(2000) + .getEditorValue((content) => { + browser.assert.ok(content.indexOf('"name": "@openzeppelin/contracts-upgradeable"') !== -1, 'package.json should contain package name') + browser.assert.ok(content.indexOf('"version"') !== -1, 'package.json should contain version') + browser.assert.ok(content.indexOf('"dependencies"') !== -1 || content.indexOf('"peerDependencies"') !== -1, 'package.json should contain dependencies') + }) + .end() + }, + + 'Test workspace package.json version resolution #group2': function (browser: NightwatchBrowser) { + browser + .clickLaunchIcon('filePanel') + .click('li[data-id="treeViewLitreeViewItemREADME.txt"') + // Create a package.json specifying OpenZeppelin version + .addFile('package.json', sources[1]['package.json']) + .addFile('TokenWithDeps.sol', sources[1]['TokenWithDeps.sol']) + .clickLaunchIcon('solidity') + .click('[data-id="compilerContainerCompileBtn"]') + .pause(2000) // Wait for compilation + .clickLaunchIcon('filePanel') + .click('*[data-id="treeViewDivDraggableItem.deps"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm/@openzeppelin"]') + // Verify the correct version from package.json was used (4.8.3) + .waitForElementVisible('*[data-id="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts@4.8.3"]', 60000) + .openFile('package.json') + .setEditorValue(sources[2]['package.json'].content) // Change to OpenZeppelin 5.4.0 + .pause(1000) + .openFile('TokenWithDeps.sol') + .waitForElementVisible('*[data-id="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts@5.4.0"]', 60000) + }, + + 'Verify canonical version is used consistently #group2': function (browser: NightwatchBrowser) { + browser + // Click on the versioned folder + .click('*[data-id="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts@4.8.3"]') + .waitForElementVisible('*[data-id="treeViewLitreeViewItem.deps/npm/@openzeppelin/contracts@4.8.3/package.json"]') + .openFile('.deps/npm/@openzeppelin/contracts@4.8.3/package.json') + .pause(1000) + .getEditorValue((content) => { + const packageJson = JSON.parse(content) + browser.assert.ok(packageJson.version === '4.8.3', 'Should use version 4.8.3 from workspace package.json') + }) + .end() + }, + + 'Test explicit versioned imports #group3': function (browser: NightwatchBrowser) { + browser + .clickLaunchIcon('filePanel') + .click('li[data-id="treeViewLitreeViewItemREADME.txt"') + .addFile('ExplicitVersions.sol', sources[3]['ExplicitVersions.sol']) + .clickLaunchIcon('solidity') + .click('[data-id="compilerContainerCompileBtn"]') + .pause(1000) + .clickLaunchIcon('filePanel') + .click('*[data-id="treeViewDivDraggableItem.deps"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm/@openzeppelin"]') + // Verify only ONE version folder exists (canonical version) + .elements('css selector', '*[data-id^="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts@"]', function (result) { + // Should have only one @openzeppelin/contracts@ folder (deduplication works) + if (Array.isArray(result.value)) { + browser.assert.ok(result.value.length === 1, 'Should have exactly one versioned folder for @openzeppelin/contracts') + } + }) + }, + + 'Verify deduplication works correctly #group3': function (browser: NightwatchBrowser) { + browser + // Verify that even with explicit @4.8.3 version in imports, + // only ONE canonical version folder exists (deduplication) + .waitForElementPresent('*[data-id^="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts@"]') + // Verify package.json exists in the single canonical folder + .click('*[data-id^="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts@"]') + .waitForElementVisible('*[data-id$="contracts@4.8.3/package.json"]', 60000) + .end() + }, + + 'Test explicit version override #group4': function (browser: NightwatchBrowser) { + browser + .clickLaunchIcon('filePanel') + .click('li[data-id="treeViewLitreeViewItemREADME.txt"') + .addFile('package.json', sources[4]['package.json']) // Has @openzeppelin/contracts@4.8.3 + .addFile('ConflictingVersions.sol', sources[4]['ConflictingVersions.sol']) // Imports @5 + .clickLaunchIcon('solidity') + .click('[data-id="compilerContainerCompileBtn"]') + .pause(1000) + .clickLaunchIcon('filePanel') + // Verify that when explicit version @5 is used, it resolves to 5.x.x + .waitForElementVisible('*[data-id="treeViewDivDraggableItem.deps"]', 60000) + .click('*[data-id="treeViewDivDraggableItem.deps"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm/@openzeppelin"]') + // Should have version 5.x.x (not 4.8.3 from package.json) because explicit @5 in import + .waitForElementPresent('*[data-id^="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts@5"]', 10000) + .end() + }, + + 'Test yarn.lock version resolution #group5': function (browser: NightwatchBrowser) { + browser + .clickLaunchIcon('filePanel') + .click('li[data-id="treeViewLitreeViewItemREADME.txt"') + .addFile('yarn.lock', sources[5]['yarn.lock']) + .addFile('YarnLockTest.sol', sources[5]['YarnLockTest.sol']) + .clickLaunchIcon('solidity') + .click('[data-id="compilerContainerCompileBtn"]') + .pause(1000) // Longer pause for npm fetch + .clickLaunchIcon('filePanel') + .waitForElementVisible('*[data-id="treeViewDivDraggableItem.deps"]', 60000) + .click('*[data-id="treeViewDivDraggableItem.deps"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm/@openzeppelin"]') + // Should use version from yarn.lock (4.9.6) + .waitForElementPresent('*[data-id^="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts@4.9"]', 10000) + }, + + 'Test package-lock.json version resolution #group6': function (browser: NightwatchBrowser) { + browser + .clickLaunchIcon('filePanel') + .click('li[data-id="treeViewLitreeViewItemREADME.txt"') + .addFile('package-lock.json', sources[7]['package-lock.json']) + .addFile('PackageLockTest.sol', sources[7]['PackageLockTest.sol']) + .clickLaunchIcon('solidity') + .click('[data-id="compilerContainerCompileBtn"]') + .pause(1000) + .clickLaunchIcon('filePanel') + .waitForElementVisible('*[data-id="treeViewDivDraggableItem.deps"]', 60000) + .click('*[data-id="treeViewDivDraggableItem.deps"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm"]') + .click('*[data-id="treeViewDivDraggableItem.deps/npm/@openzeppelin"]') + // Should use version from package-lock.json (4.8.1) + .waitForElementPresent('*[data-id^="treeViewDivDraggableItem.deps/npm/@openzeppelin/contracts@4.8.1"]', 10000) + .end() + }, + +} + +const sources = [ + { + // Test basic upgradeable contracts import + 'UpgradeableNFT.sol': { + content: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155PausableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155BurnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +` + } + }, + { + // Test workspace package.json version resolution + 'package.json': { + content: `{ + "name": "test-workspace", + "version": "1.0.0", + "dependencies": { + "@openzeppelin/contracts": "4.8.3" + } +}` + }, + 'TokenWithDeps.sol': { + content: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MyToken is ERC20 { + constructor() ERC20("MyToken", "MTK") {} +}` + } + }, + { + // Test workspace package.json version resolution + 'package.json': { + content: `{ + "name": "test-workspace", + "version": "1.0.0", + "dependencies": { + "@openzeppelin/contracts": "5.4.0" + } +}` + }, + 'TokenWithDeps.sol': { + content: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MyToken is ERC20 { + constructor() ERC20("MyToken", "MTK") {} +}` + } + }, + { + // Test explicit versioned imports get deduplicated + 'ExplicitVersions.sol': { + content: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts@4.8.3/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts@4.8.3/token/ERC20/ERC20.sol"; + +contract MyToken is ERC20 { + // Both imports should resolve to same canonical version (deduplication) + constructor() ERC20("MyToken", "MTK") {} + + function testInterface(IERC20 token) public view returns (uint256) { + return token.totalSupply(); + } +}` + } + }, + { + // Test version conflict scenarios + 'package.json': { + content: `{ + "name": "conflict-test", + "version": "1.0.0", + "dependencies": { + "@openzeppelin/contracts": "4.8.3" + } +}` + }, + 'ConflictingVersions.sol': { + content: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +// Package.json has 4.8.3, but we explicitly request 5 +import "@openzeppelin/contracts@5/token/ERC20/IERC20.sol"; + +contract MyToken {}` + } + }, + { + // Test yarn.lock version resolution (group 5) + 'yarn.lock': { + content: `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +"@openzeppelin/contracts@^4.9.0": + version "4.9.6" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.6.tgz" + integrity sha512-xSmezSupL+y9VkHZJGDoCBpmnB2ogM13ccaYDWqJTfS3dy96XIBCrAtOzko4xtrkR9Nj/Ox+oF+Y5C+RqXoRWA== +` + }, + 'YarnLockTest.sol': { + content: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MyToken is ERC20 { + // Should resolve to 4.9.6 from yarn.lock + constructor() ERC20("MyToken", "MTK") {} +}` + } + }, + { + // Test yarn.lock change detection (group 5) - Changed version to 4.7.3 + 'yarn.lock': { + content: `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +"@openzeppelin/contracts@^4.7.0": + version "4.7.3" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.7.3.tgz" + integrity sha512-dGRS0agJzu8ybo44pCIf3xBaPQN/65AIXNgK8+4gzKd5kbvlqyxryUYVLJv7fK98Seyd2hDzVEHSWAh0Bt1Yw== +` + } + }, + { + // Test package-lock.json version resolution (group 6) + 'package-lock.json': { + content: `{ + "name": "remix-project", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "remix-project", + "version": "1.0.0", + "dependencies": { + "@openzeppelin/contracts": "^4.8.0" + } + }, + "node_modules/@openzeppelin/contracts": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.8.1.tgz", + "integrity": "sha512-xQ6v385CMc2Qnn1H3bKXB8gEtXCCB8iYS4Y4BS3XgNpvBzXDgLx4NN8q8TV3B0S7o0+yD4CRBb/2W2mlYWKHdg==" + } + } +}` + }, + 'PackageLockTest.sol': { + content: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MyToken is ERC20 { + // Should resolve to 4.8.1 from package-lock.json + constructor() ERC20("MyToken", "MTK") {} +}` + } + }, + { + // Test package-lock.json change detection (group 6) - Changed version to 4.6.0 + 'package-lock.json': { + content: `{ + "name": "remix-project", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "remix-project", + "version": "1.0.0", + "dependencies": { + "@openzeppelin/contracts": "^4.6.0" + } + }, + "node_modules/@openzeppelin/contracts": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.6.0.tgz", + "integrity": "sha512-8vi4d50NNya/bQqCTNr9oGZXGQs7VRuXVZ5ivW7s3t+a76p/sU4Mbq3XBT3aKfpixiO14SV1jqFoXsdyHYiP8g==" + } + } +}` + } + } +] diff --git a/apps/remix-ide/src/app/plugins/parser/services/code-parser-compiler.ts b/apps/remix-ide/src/app/plugins/parser/services/code-parser-compiler.ts index d09dc850e93..5fa38c53f9e 100644 --- a/apps/remix-ide/src/app/plugins/parser/services/code-parser-compiler.ts +++ b/apps/remix-ide/src/app/plugins/parser/services/code-parser-compiler.ts @@ -1,6 +1,6 @@ 'use strict' import { CompilerAbstract } from '@remix-project/remix-solidity' -import { Compiler } from '@remix-project/remix-solidity' +import { Compiler, ImportResolver } from '@remix-project/remix-solidity' import { CompilationResult, CompilationSource } from '@remix-project/remix-solidity' import { CodeParser } from "../code-parser"; @@ -119,7 +119,13 @@ export default class CodeParserCompiler { this.plugin.emit('astFinished') } - this.compiler = new Compiler((url, cb) => this.plugin.call('contentImport', 'resolveAndSave', url, undefined).then((result) => cb(null, result)).catch((error) => cb(error.message))) + this.compiler = new Compiler( + (url, cb) => { return this.plugin.call('contentImport', 'resolveAndSave', url).then((result) => cb(null, result)).catch((error: Error) => cb(error.message)) }, + (target) => { + // Factory function: creates a new ImportResolver for each compilation + return new ImportResolver(this.plugin, target) + } + ) this.compiler.event.register('compilationFinished', this.onAstFinished) } diff --git a/apps/remix-ide/src/app/tabs/compile-tab.js b/apps/remix-ide/src/app/tabs/compile-tab.js index 08c7e7e67b7..671bb76fae0 100644 --- a/apps/remix-ide/src/app/tabs/compile-tab.js +++ b/apps/remix-ide/src/app/tabs/compile-tab.js @@ -36,7 +36,8 @@ export default class CompileTab extends CompilerApiMixin(ViewPlugin) { // implem this.fileManager = fileManager this.config = config this.queryParams = new QueryParams() - this.compileTabLogic = new CompileTabLogic(this, this.contentImport) + // Pass 'this' as the plugin reference so CompileTabLogic can access contentImport via this.call() + this.compileTabLogic = new CompileTabLogic(this) this.compiler = this.compileTabLogic.compiler this.compileTabLogic.init() this.initCompilerApi() diff --git a/apps/solidity-compiler/src/app/compiler.ts b/apps/solidity-compiler/src/app/compiler.ts index 72f88974349..69973170ac7 100644 --- a/apps/solidity-compiler/src/app/compiler.ts +++ b/apps/solidity-compiler/src/app/compiler.ts @@ -18,7 +18,7 @@ export class CompilerClientApi extends CompilerApiMixin(PluginClient) implements constructor () { super() createClient(this as any) - this.compileTabLogic = new CompileTabLogic(this, this.contentImport) + this.compileTabLogic = new CompileTabLogic(this) this.compiler = this.compileTabLogic.compiler this.compileTabLogic.init() this.initCompilerApi() diff --git a/libs/remix-core-plugin/src/lib/compiler-content-imports.ts b/libs/remix-core-plugin/src/lib/compiler-content-imports.ts index df715721d2d..82c5bbf1f9d 100644 --- a/libs/remix-core-plugin/src/lib/compiler-content-imports.ts +++ b/libs/remix-core-plugin/src/lib/compiler-content-imports.ts @@ -6,7 +6,7 @@ const profile = { name: 'contentImport', displayName: 'content import', version: '0.0.1', - methods: ['resolve', 'resolveAndSave', 'isExternalUrl', 'resolveGithubFolder'] + methods: ['resolve', 'resolveAndSave', 'isExternalUrl', 'resolveGithubFolder', 'resolveImportFromIndex'] } export type ResolvedImport = { @@ -17,6 +17,7 @@ export type ResolvedImport = { export class CompilerImports extends Plugin { urlResolver: any + constructor () { super(profile) this.urlResolver = new RemixURLResolver(async () => { @@ -49,7 +50,9 @@ export class CompilerImports extends Plugin { onActivation(): void { const packageFiles = ['package.json', 'package-lock.json', 'yarn.lock'] - this.on('filePanel', 'setWorkspace', () => this.urlResolver.clearCache()) + this.on('filePanel', 'setWorkspace', () => { + this.urlResolver.clearCache() + }) this.on('fileManager', 'fileRemoved', (file: string) => { if (packageFiles.includes(file)) { this.urlResolver.clearCache() @@ -62,6 +65,53 @@ export class CompilerImports extends Plugin { }) } + /** + * Resolve an import path using the persistent resolution index + * This is used by the editor for "Go to Definition" navigation + */ + async resolveImportFromIndex(sourceFile: string, importPath: string): Promise { + const indexPath = '.deps/npm/.resolution-index.json' + + try { + // Just read the file directly! + const exists = await this.call('fileManager', 'exists', indexPath) + if (!exists) { + console.log('[CompilerImports] โ„น๏ธ No resolution index file found') + return null + } + + const content = await this.call('fileManager', 'readFile', indexPath) + const index = JSON.parse(content) + + console.log('[CompilerImports] ๐Ÿ” Looking up:', { sourceFile, importPath }) + console.log('[CompilerImports] ๐Ÿ“Š Index has', Object.keys(index).length, 'source files') + + // First try: lookup using the current file (works if currentFile is a base file) + if (index[sourceFile] && index[sourceFile][importPath]) { + const resolved = index[sourceFile][importPath] + console.log('[CompilerImports] โœ… Direct lookup result:', resolved) + return resolved + } + + // Second try: search across ALL base files (works if currentFile is a library file) + console.log('[CompilerImports] ๐Ÿ” Trying lookupAny across all source files...') + for (const file in index) { + if (index[file][importPath]) { + const resolved = index[file][importPath] + console.log('[CompilerImports] โœ… Found in', file, ':', resolved) + return resolved + } + } + + console.log('[CompilerImports] โ„น๏ธ Import not found in index') + return null + + } catch (err) { + console.log('[CompilerImports] โš ๏ธ Failed to read resolution index:', err) + return null + } + } + async setToken () { try { const protocol = typeof window !== 'undefined' && window.location.protocol @@ -84,11 +134,11 @@ export class CompilerImports extends Plugin { } /** - * resolve the content of @arg url. This only resolves external URLs. - * - * @param {String} url - external URL of the content. can be basically anything like raw HTTP, ipfs URL, github address etc... - * @returns {Promise} - { content, cleanUrl, type, url } - */ + * resolve the content of @arg url. This only resolves external URLs. + * + * @param {String} url - external URL of the content. can be basically anything like raw HTTP, ipfs URL, github address etc... + * @returns {Promise} - { content, cleanUrl, type, url } + */ resolve (url) { return new Promise((resolve, reject) => { this.import(url, null, (error, content, cleanUrl, type, url) => { @@ -108,8 +158,6 @@ export class CompilerImports extends Plugin { if (!loadingCb) loadingCb = () => {} if (!cb) cb = () => {} - const self = this - let resolved try { await this.setToken() @@ -140,6 +188,27 @@ export class CompilerImports extends Plugin { }) } + /** + * import the content of @arg url. /** + * resolve the content of @arg url. This only resolves external URLs. + return new Promise((resolve, reject) => { + this.import(url, + // TODO: handle this event + (loadingMsg) => { this.emit('message', loadingMsg) }, + async (error, content, cleanUrl, type, url) => { + if (error) return reject(error) + try { + const provider = await this.call('fileManager', 'getProviderOf', null) + const path = targetPath || type + '/' + cleanUrl + if (provider) await provider.addExternal('.deps/' + path, content, url) + } catch (err) { + console.error(err) + } + resolve(content) + }, null) + }) + } + /** * import the content of @arg url. * first look in the browser localstorage (browser explorer) or localhost explorer. if the url start with `browser/*` or `localhost/*` @@ -148,9 +217,10 @@ export class CompilerImports extends Plugin { * * @param {String} url - URL of the content. can be basically anything like file located in the browser explorer, in the localhost explorer, raw HTTP, github address etc... * @param {String} targetPath - (optional) internal path where the content should be saved to + * @param {Boolean} skipMappings - (optional) unused parameter, kept for backward compatibility * @returns {Promise} - string content */ - async resolveAndSave (url, targetPath) { + async resolveAndSave (url, targetPath, skipMappings = false) { try { if (targetPath && this.currentRequest) { const canCall = await this.askUserPermission('resolveAndSave', 'This action will update the path ' + targetPath) diff --git a/libs/remix-solidity/src/compiler/compiler.ts b/libs/remix-solidity/src/compiler/compiler.ts index 5b2a30ef5c8..67670fa0136 100644 --- a/libs/remix-solidity/src/compiler/compiler.ts +++ b/libs/remix-solidity/src/compiler/compiler.ts @@ -4,6 +4,7 @@ import { update } from 'solc/abi' import compilerInput, { compilerInputForConfigFile } from './compiler-input' import EventManager from '../lib/eventManager' import txHelper from './helper' +import { IImportResolver } from './import-resolver-interface' import { Source, SourceWithTarget, MessageFromWorker, CompilerState, CompilationResult, @@ -20,9 +21,20 @@ export class Compiler { state: CompilerState handleImportCall workerHandler: EsWebWorkerHandlerInterface - constructor(handleImportCall?: (fileurl: string, cb) => void) { + importResolverFactory: ((target: string) => IImportResolver) | null // Factory to create resolvers per compilation + currentResolver: IImportResolver | null // Current compilation's import resolver + + constructor( + handleImportCall?: (fileurl: string, cb) => void, + importResolverFactory?: (target: string) => IImportResolver + ) { this.event = new EventManager() this.handleImportCall = handleImportCall + this.importResolverFactory = importResolverFactory || null + this.currentResolver = null + + console.log(`[Compiler] ๐Ÿ—๏ธ Constructor: importResolverFactory provided:`, !!importResolverFactory) + this.state = { viaIR: false, compileJSON: null, @@ -86,11 +98,19 @@ export class Compiler { if (timeStamp < this.state.compilationStartTime && this.state.compilerRetriggerMode == CompilerRetriggerMode.retrigger ) { return } + const fileCount = Object.keys(files).length + const missingCount = missingInputs?.length || 0 + console.log(`[Compiler] ๐Ÿ”„ internalCompile called with ${fileCount} file(s), ${missingCount} missing input(s) to resolve`) + this.gatherImports(files, missingInputs, (error, input) => { if (error) { + console.log(`[Compiler] โŒ gatherImports failed:`, error) this.state.lastCompilationResult = null this.event.trigger('compilationFinished', [false, { error: { formattedMessage: error, severity: 'error' } }, files, input, this.state.currentVersion]) - } else if (this.state.compileJSON && input) { this.state.compileJSON(input, timeStamp) } + } else if (this.state.compileJSON && input) { + console.log(`[Compiler] โœ… All imports gathered, sending ${Object.keys(input.sources).length} file(s) to compiler`) + this.state.compileJSON(input, timeStamp) + } }) } @@ -101,6 +121,23 @@ export class Compiler { */ compile(files: Source, target: string): void { + console.log(`\n${'='.repeat(80)}`) + console.log(`[Compiler] ๐Ÿš€ Starting NEW compilation for target: "${target}"`) + console.log(`[Compiler] ๐Ÿ“ Initial files provided: ${Object.keys(files).length}`) + console.log(`[Compiler] ๐Ÿ”Œ importResolverFactory available:`, !!this.importResolverFactory) + + // Create a fresh ImportResolver instance for this compilation + // This ensures complete isolation of import mappings per compilation + if (this.importResolverFactory) { + this.currentResolver = this.importResolverFactory(target) + console.log(`[Compiler] ๐Ÿ†• Created new resolver instance for this compilation`) + } else { + this.currentResolver = null + console.log(`[Compiler] โš ๏ธ No resolver factory - import resolution will use legacy callback`) + } + + console.log(`${'='.repeat(80)}\n`) + this.state.target = target this.state.compilationStartTime = new Date().getTime() this.event.trigger('compilationStarted', []) @@ -173,12 +210,35 @@ export class Compiler { if (data.errors) data.errors.forEach((err) => checkIfFatalError(err)) if (!noFatalErrors) { // There are fatal errors, abort here + console.log(`[Compiler] โŒ Compilation failed with errors for target: "${this.state.target}"`) + + // Clean up resolver on error + if (this.currentResolver) { + console.log(`[Compiler] ๐Ÿงน Compilation failed, discarding resolver`) + this.currentResolver = null + } + this.state.lastCompilationResult = null this.event.trigger('compilationFinished', [false, data, source, input, version]) } else if (missingInputs !== undefined && missingInputs.length > 0 && source && source.sources) { // try compiling again with the new set of inputs + console.log(`[Compiler] ๐Ÿ”„ Compilation round complete, but found ${missingInputs.length} missing input(s):`, missingInputs) + console.log(`[Compiler] ๐Ÿ” Re-compiling with new imports (sequential resolution will start)...`) + // Keep resolver alive for next round this.internalCompile(source.sources, missingInputs, timeStamp) } else { + console.log(`[Compiler] โœ… ๐ŸŽ‰ Compilation successful for target: "${this.state.target}"`) + + // Save resolution index before cleaning up resolver + if (this.currentResolver) { + console.log(`[Compiler] ๐Ÿ’พ Saving resolution index...`) + this.currentResolver.saveResolutionsToIndex().catch(err => { + console.log(`[Compiler] โš ๏ธ Failed to save resolution index:`, err) + }) + console.log(`[Compiler] ๐Ÿงน Compilation successful, discarding resolver`) + this.currentResolver = null + } + data = this.updateInterface(data) if (source) { source.target = this.state.target @@ -372,24 +432,70 @@ export class Compiler { gatherImports(files: Source, importHints?: string[], cb?: gatherImportsCallbackInterface): void { importHints = importHints || [] + const remainingCount = importHints.length + + if (remainingCount > 0) { + console.log(`[Compiler] ๐Ÿ“ฆ gatherImports: ${remainingCount} import(s) remaining in queue`) + } + while (importHints.length > 0) { const m: string = importHints.pop() as string - if (m && m in files) continue + if (m && m in files) { + console.log(`[Compiler] โญ๏ธ Skipping "${m}" - already loaded`) + continue + } - if (this.handleImportCall) { + // Try to use the ImportResolver first, fall back to legacy handleImportCall + if (this.currentResolver) { + const position = remainingCount - importHints.length + console.log(`[Compiler] ๐Ÿ” [${position}/${remainingCount}] Resolving import via ImportResolver: "${m}"`) + + this.currentResolver.resolveAndSave(m) + .then(content => { + console.log(`[Compiler] โœ… [${position}/${remainingCount}] Successfully resolved: "${m}" (${content?.length || 0} bytes)`) + files[m] = { content } + console.log(`[Compiler] ๏ฟฝ Recursively calling gatherImports for remaining ${importHints.length} import(s)`) + this.gatherImports(files, importHints, cb) + }) + .catch(err => { + console.log(`[Compiler] โŒ [${position}/${remainingCount}] Failed to resolve: "${m}"`) + // Format error message to match handleImportCall pattern + const errorMessage = err && typeof err === 'object' && err.message + ? err.message + : (typeof err === 'string' ? err : String(err)) + console.log(`[Compiler] โŒ Error details:`, errorMessage) + if (cb) cb(errorMessage) + }) + return + } else if (this.handleImportCall) { + const position = remainingCount - importHints.length + console.log(`[Compiler] ๏ฟฝ๐Ÿ” [${position}/${remainingCount}] Resolving import via legacy callback: "${m}"`) + this.handleImportCall(m, (err, content: string) => { - if (err && cb) cb(err) - else { + if (err) { + console.log(`[Compiler] โŒ [${position}/${remainingCount}] Failed to resolve: "${m}" - Error: ${err}`) + if (cb) cb(err) + } else { + console.log(`[Compiler] โœ… [${position}/${remainingCount}] Successfully resolved: "${m}" (${content?.length || 0} bytes)`) files[m] = { content } + + console.log(`[Compiler] ๐Ÿ”„ Recursively calling gatherImports for remaining ${importHints.length} import(s)`) this.gatherImports(files, importHints, cb) } }) } return } + console.log(`[Compiler] โœจ All imports resolved! Total files: ${Object.keys(files).length}`) + + // Don't clean up resolver here - it needs to survive across multiple compilation rounds + // The resolver will be cleaned up in onCompilationFinished when compilation truly completes + if (cb) { cb(null, { sources: files }) } } + + /** * @dev Truncate version string * @param version version diff --git a/libs/remix-solidity/src/compiler/import-resolver-interface.ts b/libs/remix-solidity/src/compiler/import-resolver-interface.ts new file mode 100644 index 00000000000..68146fb507a --- /dev/null +++ b/libs/remix-solidity/src/compiler/import-resolver-interface.ts @@ -0,0 +1,23 @@ +/** + * Interface for import resolution + * Allows the Compiler to remain agnostic about how imports are resolved + */ +export interface IImportResolver { + /** + * Resolve an import path and return its content + * @param url - The import path to resolve + * @returns Promise resolving to the file content + */ + resolveAndSave(url: string): Promise + + /** + * Save the current compilation's resolutions to persistent storage + * Called after successful compilation + */ + saveResolutionsToIndex(): Promise + + /** + * Get the target file for this compilation + */ + getTargetFile(): string +} diff --git a/libs/remix-solidity/src/compiler/import-resolver.ts b/libs/remix-solidity/src/compiler/import-resolver.ts new file mode 100644 index 00000000000..f718e1aa6e9 --- /dev/null +++ b/libs/remix-solidity/src/compiler/import-resolver.ts @@ -0,0 +1,745 @@ +'use strict' + +import { Plugin } from '@remixproject/engine' +import { ResolutionIndex } from './resolution-index' +import { IImportResolver } from './import-resolver-interface' + +export class ImportResolver implements IImportResolver { + private importMappings: Map + private pluginApi: Plugin + private targetFile: string + private resolutions: Map = new Map() + private workspaceResolutions: Map = new Map() // From package.json resolutions/overrides + private lockFileVersions: Map = new Map() // From yarn.lock or package-lock.json + private conflictWarnings: Set = new Set() // Track warned conflicts + private importedFiles: Map = new Map() // Track imported files: "pkg/path/to/file.sol" -> "version" + private packageSources: Map = new Map() // Track which package.json resolved each dependency: "pkg" -> "source-package" + + // Shared resolution index across all ImportResolver instances + private static resolutionIndex: ResolutionIndex | null = null + private static resolutionIndexInitialized: boolean = false + + constructor(pluginApi: Plugin, targetFile: string) { + this.pluginApi = pluginApi + this.targetFile = targetFile + this.importMappings = new Map() + + console.log(`[ImportResolver] ๐Ÿ†• Created new resolver instance for: "${targetFile}"`) + + // Initialize shared resolution index on first use + if (!ImportResolver.resolutionIndexInitialized) { + ImportResolver.resolutionIndexInitialized = true + ImportResolver.resolutionIndex = new ResolutionIndex(this.pluginApi) + ImportResolver.resolutionIndex.load().catch(err => { + console.log(`[ImportResolver] โš ๏ธ Failed to load resolution index:`, err) + }) + + // Set up workspace change listeners after a short delay to ensure plugin system is ready + setTimeout(() => { + if (ImportResolver.resolutionIndex) { + ImportResolver.resolutionIndex.onActivation() + } + }, 100) + } + + // Initialize workspace resolution rules + this.initializeWorkspaceResolutions().catch(err => { + console.log(`[ImportResolver] โš ๏ธ Failed to initialize workspace resolutions:`, err) + }) + } + + public clearMappings(): void { + console.log(`[ImportResolver] ๐Ÿงน Clearing all import mappings`) + this.importMappings.clear() + } + + /** + * Initialize workspace-level resolution rules + * Priority: 1) package.json resolutions/overrides, 2) lock files + */ + private async initializeWorkspaceResolutions(): Promise { + try { + // 1. Check for workspace package.json resolutions/overrides + await this.loadWorkspaceResolutions() + + // 2. Parse lock files for installed versions + await this.loadLockFileVersions() + + console.log(`[ImportResolver] ๐Ÿ“‹ Workspace resolutions loaded:`, this.workspaceResolutions.size) + console.log(`[ImportResolver] ๐Ÿ”’ Lock file versions loaded:`, this.lockFileVersions.size) + } catch (err) { + console.log(`[ImportResolver] โš ๏ธ Error initializing workspace resolutions:`, err) + } + } + + /** + * Load resolutions/overrides from workspace package.json + */ + private async loadWorkspaceResolutions(): Promise { + try { + const exists = await this.pluginApi.call('fileManager', 'exists', 'package.json') + if (!exists) return + + const content = await this.pluginApi.call('fileManager', 'readFile', 'package.json') + const packageJson = JSON.parse(content) + + // Yarn resolutions or npm overrides + const resolutions = packageJson.resolutions || packageJson.overrides || {} + + for (const [pkg, version] of Object.entries(resolutions)) { + if (typeof version === 'string') { + this.workspaceResolutions.set(pkg, version) + console.log(`[ImportResolver] ๐Ÿ“Œ Workspace resolution: ${pkg} โ†’ ${version}`) + } + } + + // Also check dependencies and peerDependencies for version hints + // These are lower priority than explicit resolutions/overrides + const allDeps = { + ...(packageJson.dependencies || {}), + ...(packageJson.peerDependencies || {}), + ...(packageJson.devDependencies || {}) + } + + for (const [pkg, versionRange] of Object.entries(allDeps)) { + // Only store if not already set by resolutions/overrides + if (!this.workspaceResolutions.has(pkg) && typeof versionRange === 'string') { + // For exact versions (e.g., "4.8.3"), store directly + // For ranges (e.g., "^4.8.0"), we'll need the lock file or npm to resolve + if (versionRange.match(/^\d+\.\d+\.\d+$/)) { + // Exact version - store it + this.workspaceResolutions.set(pkg, versionRange) + console.log(`[ImportResolver] ๐Ÿ“ฆ Workspace dependency (exact): ${pkg} โ†’ ${versionRange}`) + } else { + // Range - just log it, lock file or npm will resolve + console.log(`[ImportResolver] ๐Ÿ“ฆ Workspace dependency (range): ${pkg}@${versionRange}`) + } + } + } + } catch (err) { + console.log(`[ImportResolver] โ„น๏ธ No workspace package.json or resolutions`) + } + } + + /** + * Parse lock file to get actual installed versions + */ + private async loadLockFileVersions(): Promise { + // Clear existing lock file versions to pick up changes + this.lockFileVersions.clear() + + // Try yarn.lock first + try { + const yarnLockExists = await this.pluginApi.call('fileManager', 'exists', 'yarn.lock') + if (yarnLockExists) { + await this.parseYarnLock() + return + } + } catch (err) { + console.log(`[ImportResolver] โ„น๏ธ No yarn.lock found`) + } + + // Try package-lock.json + try { + const npmLockExists = await this.pluginApi.call('fileManager', 'exists', 'package-lock.json') + if (npmLockExists) { + await this.parsePackageLock() + return + } + } catch (err) { + console.log(`[ImportResolver] โ„น๏ธ No package-lock.json found`) + } + } + + /** + * Parse yarn.lock to extract package versions + */ + private async parseYarnLock(): Promise { + try { + const content = await this.pluginApi.call('fileManager', 'readFile', 'yarn.lock') + + // Simple yarn.lock parsing - look for package@version entries + const lines = content.split('\n') + let currentPackage = null + + for (const line of lines) { + // Match: "@openzeppelin/contracts@^5.0.0": or "lodash@^4.17.0": + // For scoped packages: "@scope/package@version" + // For regular packages: "package@version" + const packageMatch = line.match(/^"?(@?[^"@]+(?:\/[^"@]+)?)@[^"]*"?:/) + if (packageMatch) { + currentPackage = packageMatch[1] + } + + // Match: version "5.4.0" + const versionMatch = line.match(/^\s+version\s+"([^"]+)"/) + if (versionMatch && currentPackage) { + this.lockFileVersions.set(currentPackage, versionMatch[1]) + console.log(`[ImportResolver] ๐Ÿ”’ Lock file: ${currentPackage} โ†’ ${versionMatch[1]}`) + currentPackage = null + } + } + } catch (err) { + console.log(`[ImportResolver] โš ๏ธ Failed to parse yarn.lock:`, err) + } + } + + /** + * Parse package-lock.json to extract package versions + */ + private async parsePackageLock(): Promise { + try { + const content = await this.pluginApi.call('fileManager', 'readFile', 'package-lock.json') + const lockData = JSON.parse(content) + + // npm v1/v2 format + if (lockData.dependencies) { + for (const [pkg, data] of Object.entries(lockData.dependencies)) { + if (data && typeof data === 'object' && 'version' in data) { + this.lockFileVersions.set(pkg, (data as any).version) + console.log(`[ImportResolver] ๐Ÿ”’ Lock file: ${pkg} โ†’ ${(data as any).version}`) + } + } + } + + // npm v3 format + if (lockData.packages) { + for (const [path, data] of Object.entries(lockData.packages)) { + if (data && typeof data === 'object' && 'version' in data) { + // Skip root package (path is empty string "") + if (path === '') continue + + // Extract package name from path + // Format: "node_modules/@openzeppelin/contracts" -> "@openzeppelin/contracts" + const pkg = path.replace(/^node_modules\//, '') + if (pkg && pkg !== '') { + this.lockFileVersions.set(pkg, (data as any).version) + console.log(`[ImportResolver] ๐Ÿ”’ Lock file: ${pkg} โ†’ ${(data as any).version}`) + } + } + } + } + } catch (err) { + console.log(`[ImportResolver] โš ๏ธ Failed to parse package-lock.json:`, err) + } + } + + public logMappings(): void { + console.log(`[ImportResolver] ๐Ÿ“Š Current import mappings for: "${this.targetFile}"`) + if (this.importMappings.size === 0) { + console.log(`[ImportResolver] โ„น๏ธ No mappings defined`) + } else { + this.importMappings.forEach((value, key) => { + console.log(`[ImportResolver] ${key} โ†’ ${value}`) + }) + } + } + + private extractPackageName(url: string): string | null { + const scopedMatch = url.match(/^(@[^/]+\/[^/@]+)/) + if (scopedMatch) { + return scopedMatch[1] + } + + const regularMatch = url.match(/^([^/@]+)/) + if (regularMatch) { + return regularMatch[1] + } + + return null + } + + private extractVersion(url: string): string | null { + // Match version after @ symbol: pkg@1.2.3 or @scope/pkg@1.2.3 + // Also matches partial versions: @5, @5.0, @5.0.2 + const match = url.match(/@(\d+(?:\.\d+)?(?:\.\d+)?[^\s/]*)/) + return match ? match[1] : null + } + + private extractRelativePath(url: string, packageName: string): string | null { + // Extract the relative path after the package name (and optional version) + // Examples: + // "@openzeppelin/contracts@5.0.2/token/ERC20/IERC20.sol" -> "token/ERC20/IERC20.sol" + // "@openzeppelin/contracts/token/ERC20/IERC20.sol" -> "token/ERC20/IERC20.sol" + const versionedPattern = new RegExp(`^${packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}@[^/]+/(.+)$`) + const versionedMatch = url.match(versionedPattern) + if (versionedMatch) { + return versionedMatch[1] + } + + const unversionedPattern = new RegExp(`^${packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/(.+)$`) + const unversionedMatch = url.match(unversionedPattern) + if (unversionedMatch) { + return unversionedMatch[1] + } + + return null + } + + /** + * Compare major versions between requested and resolved versions + * Returns true if they're different major versions + */ + private hasMajorVersionMismatch(requestedVersion: string, resolvedVersion: string): boolean { + const requestedMajor = parseInt(requestedVersion.split('.')[0]) + const resolvedMajor = parseInt(resolvedVersion.split('.')[0]) + + return !isNaN(requestedMajor) && !isNaN(resolvedMajor) && requestedMajor !== resolvedMajor + } + + /** + * Basic semver compatibility check + * Returns true if the resolved version might not satisfy the requested range + */ + private isPotentialVersionConflict(requestedRange: string, resolvedVersion: string): boolean { + // Extract major.minor.patch from resolved version + const resolvedMatch = resolvedVersion.match(/^(\d+)\.(\d+)\.(\d+)/) + if (!resolvedMatch) return false + + const [, resolvedMajor, resolvedMinor, resolvedPatch] = resolvedMatch.map(Number) + + // Handle caret ranges: ^5.4.0 means >=5.4.0 <6.0.0 + const caretMatch = requestedRange.match(/^\^(\d+)\.(\d+)\.(\d+)/) + if (caretMatch) { + const [, reqMajor, reqMinor, reqPatch] = caretMatch.map(Number) + + // Must be same major version + if (resolvedMajor !== reqMajor) return true + + // If major > 0, minor can be >= requested + if (resolvedMajor > 0) { + if (resolvedMinor < reqMinor) return true + if (resolvedMinor === reqMinor && resolvedPatch < reqPatch) return true + } + + return false + } + + // Handle tilde ranges: ~5.4.0 means >=5.4.0 <5.5.0 + const tildeMatch = requestedRange.match(/^~(\d+)\.(\d+)\.(\d+)/) + if (tildeMatch) { + const [, reqMajor, reqMinor, reqPatch] = tildeMatch.map(Number) + + if (resolvedMajor !== reqMajor) return true + if (resolvedMinor !== reqMinor) return true + if (resolvedPatch < reqPatch) return true + + return false + } + + // Handle exact version: 5.4.0 + const exactMatch = requestedRange.match(/^(\d+)\.(\d+)\.(\d+)$/) + if (exactMatch) { + return requestedRange !== resolvedVersion + } + + // Handle >= ranges + const gteMatch = requestedRange.match(/^>=(\d+)\.(\d+)\.(\d+)/) + if (gteMatch) { + const [, reqMajor, reqMinor, reqPatch] = gteMatch.map(Number) + + if (resolvedMajor < reqMajor) return true + if (resolvedMajor === reqMajor && resolvedMinor < reqMinor) return true + if (resolvedMajor === reqMajor && resolvedMinor === reqMinor && resolvedPatch < reqPatch) return true + + return false + } + + // For complex ranges or wildcards, we can't reliably determine - don't warn + return false + } + + /** + * Check if version conflict is a BREAKING change (different major versions) + * This is likely to cause compilation failures + */ + private isBreakingVersionConflict(requestedRange: string, resolvedVersion: string): boolean { + const resolvedMatch = resolvedVersion.match(/^(\d+)/) + if (!resolvedMatch) return false + const resolvedMajor = parseInt(resolvedMatch[1]) + + // Extract major version from requested range + const rangeMatch = requestedRange.match(/(\d+)/) + if (!rangeMatch) return false + const requestedMajor = parseInt(rangeMatch[1]) + + return resolvedMajor !== requestedMajor + } + + /** + * Check dependencies of a package for version conflicts + */ + private async checkPackageDependencies(packageName: string, resolvedVersion: string, packageJson: any): Promise { + const allDeps = { + ...(packageJson.dependencies || {}), + ...(packageJson.peerDependencies || {}) + } + + if (Object.keys(allDeps).length === 0) return + + const depTypes = [] + if (packageJson.dependencies) depTypes.push('dependencies') + if (packageJson.peerDependencies) depTypes.push('peerDependencies') + console.log(`[ImportResolver] ๐Ÿ”— Found ${depTypes.join(' & ')} for ${packageName}:`, Object.keys(allDeps)) + + for (const [dep, requestedRange] of Object.entries(allDeps)) { + await this.checkDependencyConflict(packageName, resolvedVersion, dep, requestedRange as string, packageJson.peerDependencies) + } + } + + /** + * Check a single dependency for version conflicts + */ + private async checkDependencyConflict( + packageName: string, + packageVersion: string, + dep: string, + requestedRange: string, + peerDependencies: any + ): Promise { + const isPeerDep = peerDependencies && dep in peerDependencies + + // IMPORTANT: Only check if this dependency is ALREADY mapped (i.e., actually imported) + // Don't recursively fetch the entire npm dependency tree! + const depMappingKey = `__PKG__${dep}` + if (!this.importMappings.has(depMappingKey)) return + + const resolvedDepPackage = this.importMappings.get(depMappingKey) + const resolvedDepVersion = this.extractVersion(resolvedDepPackage) + + if (!resolvedDepVersion || typeof requestedRange !== 'string') return + + const conflictKey = `${isPeerDep ? 'peer' : 'dep'}:${packageName}โ†’${dep}:${requestedRange}โ†’${resolvedDepVersion}` + + // Check if it looks like a potential conflict (basic semver check) + if (this.conflictWarnings.has(conflictKey) || !this.isPotentialVersionConflict(requestedRange, resolvedDepVersion)) { + return + } + + this.conflictWarnings.add(conflictKey) + + // Determine where the resolved version came from + let resolvedFrom = 'npm registry' + const sourcePackage = this.packageSources.get(dep) + if (this.workspaceResolutions.has(dep)) { + resolvedFrom = 'workspace package.json' + } else if (this.lockFileVersions.has(dep)) { + resolvedFrom = 'lock file' + } else if (sourcePackage && sourcePackage !== dep && sourcePackage !== 'workspace') { + resolvedFrom = `${sourcePackage}/package.json` + } + + // Check if this is a BREAKING change (different major versions) + const isBreaking = this.isBreakingVersionConflict(requestedRange, resolvedDepVersion) + const severity = isBreaking ? 'error' : 'warn' + const emoji = isBreaking ? '๐Ÿšจ' : 'โš ๏ธ' + + const depType = isPeerDep ? 'peerDependencies' : 'dependencies' + const warningMsg = [ + `${emoji} Version mismatch detected:`, + ` Package ${packageName}@${packageVersion} requires in ${depType}:`, + ` "${dep}": "${requestedRange}"`, + ``, + ` But actual imported version is: ${dep}@${resolvedDepVersion}`, + ` (resolved from ${resolvedFrom})`, + ``, + isBreaking ? `โš ๏ธ MAJOR VERSION MISMATCH - May cause compilation failures!` : '', + isBreaking ? `` : '', + `๐Ÿ’ก To fix, update your workspace package.json:`, + ` "${dep}": "${requestedRange}"`, + `` + ].filter(line => line !== '').join('\n') + + this.pluginApi.call('terminal', 'log', { + type: severity, + value: warningMsg + }).catch(err => { + console.warn(warningMsg) + }) + } + + /** + * Resolve a package version from workspace, lock file, or npm + */ + private async resolvePackageVersion(packageName: string): Promise<{ version: string | null, source: string }> { + // PRIORITY 1: Workspace resolutions/overrides + if (this.workspaceResolutions.has(packageName)) { + const version = this.workspaceResolutions.get(packageName) + console.log(`[ImportResolver] ๐Ÿ“Œ Using workspace resolution: ${packageName} โ†’ ${version}`) + return { version, source: 'workspace-resolution' } + } + + // PRIORITY 2: Lock file (if no workspace override) + // Reload lock files fresh each time to pick up changes + await this.loadLockFileVersions() + + if (this.lockFileVersions.has(packageName)) { + const version = this.lockFileVersions.get(packageName) + console.log(`[ImportResolver] ๐Ÿ”’ Using lock file version: ${packageName} โ†’ ${version}`) + return { version, source: 'lock-file' } + } + + // PRIORITY 3: Fetch package.json (fallback) + return await this.fetchPackageVersionFromNpm(packageName) + } + + /** + * Fetch package version from npm and save package.json + */ + private async fetchPackageVersionFromNpm(packageName: string): Promise<{ version: string | null, source: string }> { + try { + console.log(`[ImportResolver] ๐Ÿ“ฆ Fetching package.json for: ${packageName}`) + + const packageJsonUrl = `${packageName}/package.json` + const content = await this.pluginApi.call('contentImport', 'resolve', packageJsonUrl) + + const packageJson = JSON.parse(content.content || content) + if (!packageJson.version) { + return { version: null, source: 'fetched' } + } + + // Save package.json to file system for visibility and debugging + try { + const targetPath = `.deps/npm/${packageName}@${packageJson.version}/package.json` + await this.pluginApi.call('fileManager', 'setFile', targetPath, JSON.stringify(packageJson, null, 2)) + console.log(`[ImportResolver] ๐Ÿ’พ Saved package.json to: ${targetPath}`) + } catch (saveErr) { + console.log(`[ImportResolver] โš ๏ธ Failed to save package.json:`, saveErr) + } + + return { version: packageJson.version, source: 'package-json' } + } catch (err) { + console.log(`[ImportResolver] โš ๏ธ Failed to fetch package.json for ${packageName}:`, err) + return { version: null, source: 'fetched' } + } + } + + private async fetchAndMapPackage(packageName: string): Promise { + const mappingKey = `__PKG__${packageName}` + + if (this.importMappings.has(mappingKey)) { + return + } + + // Resolve version from workspace, lock file, or npm + const { version: resolvedVersion, source } = await this.resolvePackageVersion(packageName) + + if (!resolvedVersion) { + return + } + + const versionedPackageName = `${packageName}@${resolvedVersion}` + this.importMappings.set(mappingKey, versionedPackageName) + + // Record the source of this resolution + if (source === 'workspace-resolution' || source === 'lock-file') { + this.packageSources.set(packageName, 'workspace') + } else { + this.packageSources.set(packageName, packageName) // Direct fetch from npm + } + + console.log(`[ImportResolver] โœ… Mapped ${packageName} โ†’ ${versionedPackageName} (source: ${source})`) + + // Check dependencies for conflicts + await this.checkPackageDependenciesIfNeeded(packageName, resolvedVersion, source) + + console.log(`[ImportResolver] ๐Ÿ“Š Total isolated mappings: ${this.importMappings.size}`) + } + + /** + * Check package dependencies if we haven't already (when using lock file or workspace resolutions) + */ + private async checkPackageDependenciesIfNeeded(packageName: string, resolvedVersion: string, source: string): Promise { + try { + const packageJsonUrl = `${packageName}/package.json` + const content = await this.pluginApi.call('contentImport', 'resolve', packageJsonUrl) + const packageJson = JSON.parse(content.content || content) + + // Save package.json if we haven't already (when using lock file or workspace resolutions) + if (source !== 'package-json') { + try { + const targetPath = `.deps/npm/${packageName}@${resolvedVersion}/package.json` + await this.pluginApi.call('fileManager', 'setFile', targetPath, JSON.stringify(packageJson, null, 2)) + console.log(`[ImportResolver] ๐Ÿ’พ Saved package.json to: ${targetPath}`) + } catch (saveErr) { + console.log(`[ImportResolver] โš ๏ธ Failed to save package.json:`, saveErr) + } + } + + // Check dependencies for conflicts + await this.checkPackageDependencies(packageName, resolvedVersion, packageJson) + } catch (err) { + // Dependencies are optional, don't fail compilation + console.log(`[ImportResolver] โ„น๏ธ Could not check dependencies for ${packageName}`) + } + } + + public async resolveAndSave(url: string, targetPath?: string, skipResolverMappings = false): Promise { + const originalUrl = url + let finalUrl = url + const packageName = this.extractPackageName(url) + + if (!skipResolverMappings && packageName) { + const hasVersion = url.includes(`${packageName}@`) + + if (!hasVersion) { + const mappingKey = `__PKG__${packageName}` + + if (!this.importMappings.has(mappingKey)) { + console.log(`[ImportResolver] ๐Ÿ” First import from ${packageName}, fetching package.json...`) + await this.fetchAndMapPackage(packageName) + } + + if (this.importMappings.has(mappingKey)) { + const versionedPackageName = this.importMappings.get(mappingKey) + const mappedUrl = url.replace(packageName, versionedPackageName) + console.log(`[ImportResolver] ๐Ÿ”€ Applying ISOLATED mapping: ${url} โ†’ ${mappedUrl}`) + + finalUrl = mappedUrl + this.resolutions.set(originalUrl, finalUrl) + + return this.resolveAndSave(mappedUrl, targetPath, true) + } else { + console.log(`[ImportResolver] โš ๏ธ No mapping available for ${mappingKey}`) + } + } else { + // CONFLICT DETECTION: URL has explicit version, check if it conflicts with our resolution + const requestedVersion = this.extractVersion(url) + const mappingKey = `__PKG__${packageName}` + + if (this.importMappings.has(mappingKey)) { + const versionedPackageName = this.importMappings.get(mappingKey) + const resolvedVersion = this.extractVersion(versionedPackageName) + + if (requestedVersion && resolvedVersion && requestedVersion !== resolvedVersion) { + // Extract the relative file path to check for actual duplicate imports + const relativePath = this.extractRelativePath(url, packageName) + const fileKey = relativePath ? `${packageName}/${relativePath}` : null + + // Check if we've already imported this EXACT file from a different version + const previousVersion = fileKey ? this.importedFiles.get(fileKey) : null + + if (previousVersion && previousVersion !== resolvedVersion) { + // REAL CONFLICT: Same file imported from two different versions + const conflictKey = `${fileKey}:${previousVersion}โ†”${resolvedVersion}` + + if (!this.conflictWarnings.has(conflictKey)) { + this.conflictWarnings.add(conflictKey) + + // Determine the source of the resolved version + let resolutionSource = 'npm registry' + const sourcePackage = this.packageSources.get(packageName) + if (this.workspaceResolutions.has(packageName)) { + resolutionSource = 'workspace package.json' + } else if (this.lockFileVersions.has(packageName)) { + resolutionSource = 'lock file' + } else if (sourcePackage && sourcePackage !== packageName) { + resolutionSource = `${sourcePackage}/package.json` + } + + const warningMsg = [ + `๐Ÿšจ DUPLICATE FILE DETECTED - Will cause compilation errors!`, + ` File: ${relativePath}`, + ` From package: ${packageName}`, + ``, + ` Already imported from version: ${previousVersion}`, + ` Now requesting version: ${resolvedVersion}`, + ` (from ${resolutionSource})`, + ``, + `๐Ÿ”ง REQUIRED FIX - Use explicit versioned imports in your Solidity file:`, + ` Choose ONE version:`, + ` import "${packageName}@${previousVersion}/${relativePath}";`, + ` OR`, + ` import "${packageName}@${resolvedVersion}/${relativePath}";`, + ` (and update package.json: "${packageName}": "${resolvedVersion}")`, + `` + ].join('\n') + + this.pluginApi.call('terminal', 'log', { + type: 'error', + value: warningMsg + }).catch(err => { + console.warn(warningMsg) + }) + } + } else if (fileKey && !previousVersion) { + // First time seeing this file - just record which version we're using + // Don't warn about version mismatches here - only warn if same file imported twice + this.importedFiles.set(fileKey, resolvedVersion) + console.log(`[ImportResolver] ๐Ÿ“ Tracking import: ${fileKey} from version ${resolvedVersion}`) + } + + const mappedUrl = url.replace(`${packageName}@${requestedVersion}`, versionedPackageName) + finalUrl = mappedUrl + this.resolutions.set(originalUrl, finalUrl) + + return this.resolveAndSave(mappedUrl, targetPath, true) + } else if (requestedVersion && resolvedVersion && requestedVersion === resolvedVersion) { + // Versions MATCH - normalize to canonical path to prevent duplicate declarations + // This ensures "@openzeppelin/contracts@4.8.3/..." always resolves to the same path + // regardless of which import statement triggered it first + const mappedUrl = url.replace(`${packageName}@${requestedVersion}`, versionedPackageName) + if (mappedUrl !== url) { + finalUrl = mappedUrl + this.resolutions.set(originalUrl, finalUrl) + + return this.resolveAndSave(mappedUrl, targetPath, true) + } + } + } else { + // No mapping exists yet - this is the FIRST import with an explicit version + // Record it as our canonical version for this package + if (requestedVersion) { + const versionedPackageName = `${packageName}@${requestedVersion}` + this.importMappings.set(mappingKey, versionedPackageName) + + // Fetch and save package.json for this version + try { + const packageJsonUrl = `${packageName}@${requestedVersion}/package.json` + const content = await this.pluginApi.call('contentImport', 'resolve', packageJsonUrl) + const packageJson = JSON.parse(content.content || content) + + const targetPath = `.deps/npm/${versionedPackageName}/package.json` + await this.pluginApi.call('fileManager', 'setFile', targetPath, JSON.stringify(packageJson, null, 2)) + console.log(`[ImportResolver] ๐Ÿ’พ Saved package.json to: ${targetPath}`) + } catch (err) { + console.log(`[ImportResolver] โš ๏ธ Failed to fetch/save package.json:`, err) + } + } + } + } + } + + console.log(`[ImportResolver] ๐Ÿ“ฅ Fetching file (skipping ContentImport global mappings): ${url}`) + const content = await this.pluginApi.call('contentImport', 'resolveAndSave', url, targetPath, true) + if (!skipResolverMappings || originalUrl === url) { + if (!this.resolutions.has(originalUrl)) { + this.resolutions.set(originalUrl, url) + console.log(`[ImportResolver] ๏ฟฝ๏ฟฝ Recorded resolution: ${originalUrl} โ†’ ${url}`) + } + } + + return content + } + + public async saveResolutionsToIndex(): Promise { + console.log(`[ImportResolver] ๐Ÿ’พ Saving ${this.resolutions.size} resolution(s) to index for: ${this.targetFile}`) + + if (!ImportResolver.resolutionIndex) { + console.log(`[ImportResolver] โš ๏ธ Resolution index not initialized, skipping save`) + return + } + + ImportResolver.resolutionIndex.clearFileResolutions(this.targetFile) + + this.resolutions.forEach((resolvedPath, originalImport) => { + ImportResolver.resolutionIndex!.recordResolution(this.targetFile, originalImport, resolvedPath) + }) + + await ImportResolver.resolutionIndex.save() + } + + public getTargetFile(): string { + return this.targetFile + } +} diff --git a/libs/remix-solidity/src/compiler/resolution-index.ts b/libs/remix-solidity/src/compiler/resolution-index.ts new file mode 100644 index 00000000000..e6880d9d5a1 --- /dev/null +++ b/libs/remix-solidity/src/compiler/resolution-index.ts @@ -0,0 +1,214 @@ +'use strict' + +/** + * ResolutionIndex - Tracks import resolution mappings for editor navigation + * + * This creates a persistent index file at .deps/npm/.resolution-index.json + * that maps source files to their resolved import paths. + * + * Format: + * { + * "contracts/MyToken.sol": { + * "@openzeppelin/contracts/token/ERC20/ERC20.sol": "@openzeppelin/contracts@5.4.0/token/ERC20/ERC20.sol", + * "@chainlink/contracts/src/interfaces/IFeed.sol": "@chainlink/contracts@1.5.0/src/interfaces/IFeed.sol" + * } + * } + */ +import { Plugin } from '@remixproject/engine' +export class ResolutionIndex { + private pluginApi: Plugin + private indexPath: string = '.deps/npm/.resolution-index.json' + private index: Record> = {} + private isDirty: boolean = false + private loadPromise: Promise | null = null + private isLoaded: boolean = false + + constructor(pluginApi: Plugin) { + this.pluginApi = pluginApi + } + + /** + * Set up event listeners after plugin activation + * Should be called after the plugin system is ready + */ + onActivation(): void { + // Listen for workspace changes and reload the index + if (this.pluginApi && this.pluginApi.on) { + this.pluginApi.on('filePanel', 'setWorkspace', () => { + console.log(`[ResolutionIndex] ๐Ÿ”„ Workspace changed, reloading index...`) + this.reload().catch(err => { + console.log(`[ResolutionIndex] โš ๏ธ Failed to reload index after workspace change:`, err) + }) + }) + } + } + + /** + * Load the existing index from disk + */ + async load(): Promise { + // Return existing load promise if already loading + if (this.loadPromise) { + return this.loadPromise + } + + // Return immediately if already loaded + if (this.isLoaded) { + return Promise.resolve() + } + + this.loadPromise = (async () => { + try { + const exists = await this.pluginApi.call('fileManager', 'exists', this.indexPath) + if (exists) { + const content = await this.pluginApi.call('fileManager', 'readFile', this.indexPath) + this.index = JSON.parse(content) + console.log(`[ResolutionIndex] ๐Ÿ“– Loaded index with ${Object.keys(this.index).length} source files`) + } else { + console.log(`[ResolutionIndex] ๐Ÿ“ No existing index found, starting fresh`) + this.index = {} + } + this.isLoaded = true + } catch (err) { + console.log(`[ResolutionIndex] โš ๏ธ Failed to load index:`, err) + this.index = {} + this.isLoaded = true + } + })() + + return this.loadPromise + } + + /** + * Ensure the index is loaded before using it + */ + async ensureLoaded(): Promise { + if (!this.isLoaded) { + await this.load() + } + } + + /** + * Reload the index from disk (e.g., after workspace change) + * This clears the current in-memory index and reloads from the file system + */ + async reload(): Promise { + console.log(`[ResolutionIndex] ๐Ÿ”„ Reloading index (workspace changed)`) + this.index = {} + this.isDirty = false + this.isLoaded = false + this.loadPromise = null + await this.load() + } + + /** + * Record a resolution mapping for a source file + * @param sourceFile The file being compiled (e.g., "contracts/MyToken.sol") + * @param originalImport The import as written in source (e.g., "@openzeppelin/contracts/token/ERC20/ERC20.sol") + * @param resolvedPath The actual resolved path (e.g., "@openzeppelin/contracts@5.4.0/token/ERC20/ERC20.sol") + */ + recordResolution(sourceFile: string, originalImport: string, resolvedPath: string): void { + // Only record if there was an actual mapping (resolved path differs from original) + if (originalImport === resolvedPath) { + return + } + + if (!this.index[sourceFile]) { + this.index[sourceFile] = {} + } + + // Only mark dirty if this is a new or changed mapping + if (this.index[sourceFile][originalImport] !== resolvedPath) { + this.index[sourceFile][originalImport] = resolvedPath + this.isDirty = true + console.log(`[ResolutionIndex] ๐Ÿ“ Recorded: ${sourceFile} | ${originalImport} โ†’ ${resolvedPath}`) + } + } + + /** + * Look up how an import was resolved for a specific source file + * @param sourceFile The source file that contains the import + * @param importPath The import path to look up + * @returns The resolved path, or null if not found + */ + lookup(sourceFile: string, importPath: string): string | null { + if (this.index[sourceFile] && this.index[sourceFile][importPath]) { + return this.index[sourceFile][importPath] + } + return null + } + + /** + * Look up an import path across ALL source files in the index + * This is useful when navigating from library files (which aren't keys in the index) + * @param importPath The import path to look up + * @returns The resolved path from any source file that used it, or null if not found + */ + lookupAny(importPath: string): string | null { + // Search through all source files for this import + for (const sourceFile in this.index) { + if (this.index[sourceFile][importPath]) { + console.log(`[ResolutionIndex] ๐Ÿ” Found import "${importPath}" in source file "${sourceFile}":`, this.index[sourceFile][importPath]) + return this.index[sourceFile][importPath] + } + } + return null + } + + /** + * Get all resolutions for a specific source file + */ + getResolutionsForFile(sourceFile: string): Record | null { + return this.index[sourceFile] || null + } + + /** + * Clear resolutions for a specific source file + * (useful when recompiling) + */ + clearFileResolutions(sourceFile: string): void { + if (this.index[sourceFile]) { + delete this.index[sourceFile] + this.isDirty = true + console.log(`[ResolutionIndex] ๐Ÿ—‘๏ธ Cleared resolutions for: ${sourceFile}`) + } + } + + /** + * Save the index to disk if it has been modified + */ + async save(): Promise { + if (!this.isDirty) { + console.log(`[ResolutionIndex] โญ๏ธ Index unchanged, skipping save`) + return + } + + try { + const content = JSON.stringify(this.index, null, 2) + await this.pluginApi.call('fileManager', 'writeFile', this.indexPath, content) + this.isDirty = false + console.log(`[ResolutionIndex] ๐Ÿ’พ Saved index with ${Object.keys(this.index).length} source files`) + } catch (err) { + console.log(`[ResolutionIndex] โŒ Failed to save index:`, err) + } + } + + /** + * Get the full index (for debugging) + */ + getFullIndex(): Record> { + return this.index + } + + /** + * Get statistics about the index + */ + getStats(): { sourceFiles: number, totalMappings: number } { + const sourceFiles = Object.keys(this.index).length + let totalMappings = 0 + for (const file in this.index) { + totalMappings += Object.keys(this.index[file]).length + } + return { sourceFiles, totalMappings } + } +} diff --git a/libs/remix-solidity/src/index.ts b/libs/remix-solidity/src/index.ts index eba07bb04ef..4c54fd1a2c1 100644 --- a/libs/remix-solidity/src/index.ts +++ b/libs/remix-solidity/src/index.ts @@ -1,4 +1,7 @@ export { Compiler } from './compiler/compiler' +export { ImportResolver } from './compiler/import-resolver' +export { IImportResolver } from './compiler/import-resolver-interface' +export { ResolutionIndex } from './compiler/resolution-index' export { compile } from './compiler/compiler-helpers' export { default as compilerInputFactory, getValidLanguage } from './compiler/compiler-input' export { CompilerAbstract } from './compiler/compiler-abstract' diff --git a/libs/remix-ui/editor/src/lib/providers/definitionProvider.ts b/libs/remix-ui/editor/src/lib/providers/definitionProvider.ts index d74a5a07507..1dfaf323063 100644 --- a/libs/remix-ui/editor/src/lib/providers/definitionProvider.ts +++ b/libs/remix-ui/editor/src/lib/providers/definitionProvider.ts @@ -20,12 +20,32 @@ export class RemixDefinitionProvider implements monaco.languages.DefinitionProvi if (lastpart.startsWith('import')) { const importPath = line.substring(lastpart.indexOf('"') + 1) const importPath2 = importPath.substring(0, importPath.indexOf('"')) + + // Try to resolve using the resolution index + // model.uri.path gives us the current file we're in + const currentFile = model.uri.path + console.log('[DefinitionProvider] ๐Ÿ“ Import navigation from:', currentFile, 'โ†’', importPath2) + + let resolvedPath = importPath2 + try { + // Check if we have a resolution index entry for this import + const resolved = await this.props.plugin.call('contentImport', 'resolveImportFromIndex', currentFile, importPath2) + if (resolved) { + console.log('[DefinitionProvider] โœ… Found in resolution index:', resolved) + resolvedPath = resolved + } else { + console.log('[DefinitionProvider] โ„น๏ธ Not in resolution index, using original path') + } + } catch (e) { + console.log('[DefinitionProvider] โš ๏ธ Failed to lookup resolution index:', e) + } + jumpLocation = { startLineNumber: 1, startColumn: 1, endColumn: 1, endLineNumber: 1, - fileName: importPath2 + fileName: resolvedPath } } } @@ -56,10 +76,25 @@ export class RemixDefinitionProvider implements monaco.languages.DefinitionProvi */ async jumpToPosition(position: any) { const jumpToLine = async (fileName: string, lineColumn: any) => { - const fileTarget = await this.props.plugin.call('fileManager', 'getPathFromUrl', fileName) - if (fileName !== await this.props.plugin.call('fileManager', 'file')) { - await this.props.plugin.call('contentImport', 'resolveAndSave', fileName, null) - const fileContent = await this.props.plugin.call('fileManager', 'readFile', fileName) + // Try to resolve the fileName using the resolution index + // This is crucial for navigating to library files with correct versions + let resolvedFileName = fileName + try { + const currentFile = await this.props.plugin.call('fileManager', 'file') + const resolved = await this.props.plugin.call('contentImport', 'resolveImportFromIndex', currentFile, fileName) + if (resolved) { + console.log('[DefinitionProvider] ๐Ÿ”€ Resolved via index:', fileName, 'โ†’', resolved) + resolvedFileName = resolved + } + } catch (e) { + console.log('[DefinitionProvider] โš ๏ธ Resolution index lookup failed, using original path:', e) + } + + const fileTarget = await this.props.plugin.call('fileManager', 'getPathFromUrl', resolvedFileName) + console.log('jumpToLine', fileName, 'โ†’', resolvedFileName, 'โ†’', fileTarget) + if (resolvedFileName !== await this.props.plugin.call('fileManager', 'file')) { + await this.props.plugin.call('contentImport', 'resolveAndSave', resolvedFileName, null) + const fileContent = await this.props.plugin.call('fileManager', 'readFile', resolvedFileName) try { await this.props.plugin.call('editor', 'addModel', fileTarget.file, fileContent) } catch (e) { @@ -72,7 +107,7 @@ export class RemixDefinitionProvider implements monaco.languages.DefinitionProvi startColumn: lineColumn.start.column + 1, endColumn: lineColumn.end.column + 1, endLineNumber: lineColumn.end.line + 1, - fileName: (fileTarget && fileTarget.file) || fileName + fileName: (fileTarget && fileTarget.file) || resolvedFileName } return pos } diff --git a/libs/remix-ui/solidity-compiler/src/lib/logic/compileTabLogic.ts b/libs/remix-ui/solidity-compiler/src/lib/logic/compileTabLogic.ts index 5dc0474a946..0d74fc7c549 100644 --- a/libs/remix-ui/solidity-compiler/src/lib/logic/compileTabLogic.ts +++ b/libs/remix-ui/solidity-compiler/src/lib/logic/compileTabLogic.ts @@ -1,5 +1,5 @@ import { ICompilerApi } from '@remix-project/remix-lib' -import { getValidLanguage, Compiler } from '@remix-project/remix-solidity' +import { getValidLanguage, Compiler, ImportResolver } from '@remix-project/remix-solidity' import { EventEmitter } from 'events' import { configFileContent } from '../compilerConfiguration' @@ -23,11 +23,22 @@ export class CompileTabLogic { public evmVersions: Array public useFileConfiguration: boolean - constructor (api: ICompilerApi, contentImport) { + constructor (api: ICompilerApi) { this.api = api - this.contentImport = contentImport + this.event = new EventEmitter() - this.compiler = new Compiler((url, cb) => api.resolveContentAndSave(url).then((result) => cb(null, result)).catch((error) => cb(error.message))) + + + // Create compiler with both legacy callback (for backwards compatibility) + // and an import resolver factory (for new ImportResolver architecture) + this.compiler = new Compiler( + (url, cb) => api.resolveContentAndSave(url).then((result) => cb(null, result)).catch((error) => cb(error.message)), + api ? (target) => { + // Factory function: creates a new ImportResolver for each compilation + return new ImportResolver(api as any, target) + } : null + ) + this.evmVersions = ['default', 'prague', 'cancun', 'shanghai', 'paris', 'london', 'berlin', 'istanbul', 'petersburg', 'constantinople', 'byzantium', 'spuriousDragon', 'tangerineWhistle', 'homestead'] }