diff --git a/.circleci/config.yml b/.circleci/config.yml index 700a9b0b1d3..50c0db3c9b9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -14,7 +14,7 @@ parameters: default: "" run_file_tests_keyword: type: enum - enum: ["", "ai_panel", "ballot", "ballot_0_4_14", "blockchain", "bottom-bar", "circom", "code_format", "compile_run_widget", "compiler_api", "contract_flattener", "contract_verification", "debugger", "defaultLayout", "deploy_vefiry", "dgit_github", "dgit_local", "editor", "editorHoverContext", "editorReferences", "editor_error_marker", "editor_line_text", "eip1153", "eip7702", "environment-account", "erc721", "etherscan_api", "expandAllFolders", "fileExplorer", "fileManager_api", "file_decorator", "file_explorer_context_menu", "file_explorer_dragdrop", "file_explorer_multiselect", "generalSettings", "gist", "homeTab", "importFromGithub", "layout", "learneth", "libraryDeployment", "matomo-bot-detection", "matomo-consent", "mcp_all_resources", "mcp_all_tools", "mcp_server_complete", "mcp_server_connection", "mcp_server_lifecycle", "mcp_workflow_integration", "metamask", "migrateFileSystem", "noir", "pinned_contracts", "pluginManager", "plugin_api", "providers", "proxy_oz_v4", "proxy_oz_v5", "proxy_oz_v5_non_shanghai_runtime", "publishContract", "quickDapp_metamask", "recorder", "remixd", "runAndDeploy", "script-runner", "search", "signingMessage", "sol2uml", "solidityImport", "solidityUnittests", "specialFunctions", "staticAnalysis", "stressEditor", "template_exp_modal", "terminal", "toggle_panels", "transactionExecution", "txListener", "uniswap_v4_core", "url", "usingWebWorker", "verticalIconsPanel", "vm_state", "vyper_api", "walkthrough", "workspace", "workspace_git"] + enum: ["", "ai_panel", "ballot", "ballot_0_4_14", "blockchain", "bottom-bar", "circom", "code_format", "compile_run_widget", "compiler_api", "contract_flattener", "contract_verification", "debugger", "defaultLayout", "deploy_vefiry", "dgit_github", "dgit_local", "editor", "editorHoverContext", "editorReferences", "editor_autocomplete", "editor_error_marker", "editor_line_text", "eip1153", "eip7702", "environment-account", "erc721", "etherscan_api", "expandAllFolders", "fileExplorer", "fileManager_api", "file_decorator", "file_explorer_context_menu", "file_explorer_dragdrop", "file_explorer_multiselect", "generalSettings", "gist", "homeTab", "importFromGithub", "layout", "learneth", "libraryDeployment", "matomo-bot-detection", "matomo-consent", "mcp_all_resources", "mcp_all_tools", "mcp_server_complete", "mcp_server_connection", "mcp_server_lifecycle", "mcp_workflow_integration", "metamask", "migrateFileSystem", "noir", "pinned_contracts", "pluginManager", "plugin_api", "providers", "proxy_oz_v4", "proxy_oz_v5", "proxy_oz_v5_non_shanghai_runtime", "publishContract", "quickDapp_metamask", "recorder", "remixd", "runAndDeploy", "script-runner", "search", "signingMessage", "sol2uml", "solidityImport", "solidityUnittests", "specialFunctions", "staticAnalysis", "stressEditor", "template_exp_modal", "terminal", "toggle_panels", "transactionExecution", "txListener", "uniswap_v4_core", "url", "usingWebWorker", "verticalIconsPanel", "vm_state", "vyper_api", "walkthrough", "workspace", "workspace_git"] default: "" run_flaky_tests: type: boolean diff --git a/apps/remix-ide-e2e/src/tests/editor_autocomplete.test.ts b/apps/remix-ide-e2e/src/tests/editor_autocomplete.test.ts new file mode 100644 index 00000000000..5412f0e8056 --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/editor_autocomplete.test.ts @@ -0,0 +1,93 @@ +'use strict' + +import { NightwatchBrowser } from 'nightwatch' +import init from '../helpers/init' + +module.exports = { + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done, 'http://127.0.0.1:8080', true) + }, + + 'Should load external types (axios) and show autocomplete #group1': function (browser: NightwatchBrowser) { + browser + .clickLaunchIcon('filePanel') + .addFile('scripts/test_axios.ts', { content: testAxiosScript }) + .openFile('scripts/test_axios.ts') + .waitForElementVisible('#editorView', 20000) + .pause(15000) + .execute(function () { + const win = window as any + if (!win.monaco || !win.monaco.editor) return false + + const editors = win.monaco.editor.getEditors() + const activeEditor = editors.find((e: any) => { + const model = e.getModel() + return model && model.uri.toString().includes('test_axios.ts') + }) + + if (activeEditor) { + activeEditor.focus() + const model = activeEditor.getModel() + const lastLine = model.getLineCount() + const lastCol = model.getLineMaxColumn(lastLine) + activeEditor.setPosition({ lineNumber: lastLine, column: lastCol }) + activeEditor.trigger('keyboard', 'type', { text: "\naxios" }) + activeEditor.trigger('keyboard', 'type', { text: "." }) + activeEditor.trigger('keyboard', 'editor.action.triggerSuggest', {}) + return true + } + return false + }) + .pause(2000) + .waitForElementVisible('.suggest-widget', 15000) + .waitForElementVisible('.monaco-list-row', 5000) + .waitForElementContainsText('.suggest-widget', 'get', 5000) + .waitForElementContainsText('.suggest-widget', 'create', 5000) + }, + + 'Should provide autocomplete for local imports #group1': function (browser: NightwatchBrowser) { + browser + .addFile('scripts/localLib.ts', { content: localLibScript }) + .addFile('scripts/localConsumer.ts', { content: localConsumerScript }) + .openFile('scripts/localConsumer.ts') + .waitForElementVisible('#editorView', 20000) + .pause(2000) + .execute(function () { + const win = window as any + if (!win.monaco) return false + const editors = win.monaco.editor.getEditors() + const activeEditor = editors.find((e: any) => { + const model = e.getModel() + return model && model.uri.toString().includes('localConsumer.ts') + }) + + if (activeEditor) { + activeEditor.focus() + const model = activeEditor.getModel() + const lastLine = model.getLineCount() + const lastCol = model.getLineMaxColumn(lastLine) + activeEditor.setPosition({ lineNumber: lastLine, column: lastCol }) + activeEditor.trigger('keyboard', 'type', { text: "\nHelper" }) + activeEditor.trigger('keyboard', 'type', { text: "." }) + activeEditor.trigger('keyboard', 'editor.action.triggerSuggest', {}) + return true + } + return false + }) + .pause(2000) + .waitForElementVisible('.suggest-widget', 15000) + .waitForElementVisible('.monaco-list-row', 5000) + .waitForElementContainsText('.suggest-widget', 'myLocalFunction', 5000) + } +} + +const testAxiosScript = ` +import axios from 'axios'; +// Test Start` + +const localLibScript = ` +export const myLocalFunction = () => { return "Local"; }` + +const localConsumerScript = ` +import * as Helper from './localLib'; +// Test Start` \ No newline at end of file diff --git a/apps/remix-ide/src/app/editor/editor.js b/apps/remix-ide/src/app/editor/editor.js index 807b0792dda..7dfbf557c3f 100644 --- a/apps/remix-ide/src/app/editor/editor.js +++ b/apps/remix-ide/src/app/editor/editor.js @@ -1,11 +1,12 @@ 'use strict' import React from 'react' // eslint-disable-line -import { resolve } from 'path' import { EditorUI } from '@remix-ui/editor' // eslint-disable-line import { Plugin } from '@remixproject/engine' import * as packageJson from '../../../../../package.json' import { PluginViewWrapper } from '@remix-ui/helper' +import { startTypeLoadingProcess } from './type-fetcher' + const EventManager = require('../../lib/events') const profile = { @@ -75,12 +76,26 @@ export default class Editor extends Plugin { this.api = {} this.dispatch = null this.ref = null + + this.monaco = null + this.typeLoaderDebounce = null + + this.tsModuleMappings = {} + this.processedPackages = new Set() + + this.typesLoadingCount = 0 + this.shimDisposers = new Map() } + setDispatch (dispatch) { this.dispatch = dispatch } + setMonaco (monaco) { + this.monaco = monaco + } + updateComponent(state) { return this.setMonaco(monaco)} /> } @@ -130,8 +146,40 @@ export default class Editor extends Plugin { this.emit(name, ...params) // plugin stack } + resolveRelativePath(basePath, relativePath) { + const stack = basePath.split('/') + stack.pop() + + const parts = relativePath.split('/') + for (let i = 0; i < parts.length; i++) { + if (parts[i] === '.') continue + if (parts[i] === '..') stack.pop() + else stack.push(parts[i]) + } + return stack.join('/') + } + async onActivation () { this.activated = true + this.on('editor', 'editorMounted', () => { + if (!this.monaco) return + const ts = this.monaco.languages.typescript + const tsDefaults = ts.typescriptDefaults + + tsDefaults.setCompilerOptions({ + moduleResolution: ts.ModuleResolutionKind.NodeNext, + module: ts.ModuleKind.NodeNext, + target: ts.ScriptTarget.ES2022, + lib: ['es2022', 'dom', 'dom.iterable'], + allowNonTsExtensions: true, + allowSyntheticDefaultImports: true, + skipLibCheck: true, + baseUrl: 'file:///node_modules/', + paths: this.tsModuleMappings, + }) + tsDefaults.setDiagnosticsOptions({ noSemanticValidation: false, noSyntaxValidation: false }) + ts.typescriptDefaults.setEagerModelSync(true) + }) this.on('sidePanel', 'focusChanged', (name) => { this.keepDecorationsFor(name, 'sourceAnnotationsPerFile') this.keepDecorationsFor(name, 'markerPerFile') @@ -147,11 +195,26 @@ export default class Editor extends Plugin { this.currentFile = null this.renderComponent() }) + this.on('fileManager', 'currentFileChanged', (currentFile) => { + if (this.currentFile === currentFile) return + this.currentFile = currentFile + if (currentFile && (currentFile.endsWith('.ts') || currentFile.endsWith('.js') || currentFile.endsWith('.tsx') || currentFile.endsWith('.jsx'))) { + this._onChange(currentFile) + } + this.renderComponent() + }) + this.on('scriptRunnerBridge', 'runnerChanged', async () => { + this.processedPackages.clear() + this.tsModuleMappings = {} + + if (this.currentFile) { + clearTimeout(this.typeLoaderDebounce) + await this._onChange(this.currentFile) + } + }) try { this.currentThemeType = (await this.call('theme', 'currentTheme')).quality - } catch (e) { - console.log('unable to select the theme ' + e.message) - } + } catch (e) {} // eslint-disable-line no-empty this.renderComponent() } @@ -160,27 +223,208 @@ export default class Editor extends Plugin { this.off('sidePanel', 'pluginDisabled') } - async _onChange (file) { - this.triggerEvent('didChangeFile', [file]) - const currentFile = await this.call('fileManager', 'file') - if (!currentFile) { - return + updateTsCompilerOptions() { + if (!this.monaco) return + + const tsDefaults = this.monaco.languages.typescript.typescriptDefaults + const currentOptions = tsDefaults.getCompilerOptions() + + tsDefaults.setCompilerOptions({ + ...currentOptions, + paths: { ...currentOptions.paths, ...this.tsModuleMappings } + }) + } + + toggleTsDiagnostics(enable) { + if (!this.monaco) return + const ts = this.monaco.languages.typescript + ts.typescriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: !enable, + noSyntaxValidation: false + }) + } + + addShimForPackage(pkg) { + if (!this.monaco) return + const tsDefaults = this.monaco.languages.typescript.typescriptDefaults + + const shimMainPath = `file:///__shims__/${pkg}.d.ts` + const shimWildPath = `file:///__shims__/${pkg}__wildcard.d.ts` + + if (!this.shimDisposers.has(shimMainPath)) { + const d1 = tsDefaults.addExtraLib(`declare module '${pkg}' { const _default: any\nexport = _default }`, shimMainPath) + this.shimDisposers.set(shimMainPath, d1) } - if (currentFile !== file) { - return + + if (!this.shimDisposers.has(shimWildPath)) { + const d2 = tsDefaults.addExtraLib(`declare module '${pkg}/*' { const _default: any\nexport = _default }`, shimWildPath) + this.shimDisposers.set(shimWildPath, d2) } - const input = this.get(currentFile) - if (!input) { - return + + } + + removeShimsForPackage(pkg) { + const keys = [`file:///__shims__/${pkg}.d.ts`, `file:///__shims__/${pkg}__wildcard.d.ts`] + for (const k of keys) { + const disp = this.shimDisposers.get(k) + if (disp && typeof disp.dispose === 'function') { + disp.dispose() + this.shimDisposers.delete(k) + } + } + } + + beginTypesBatch() { + if (this.typesLoadingCount === 0) { + this.toggleTsDiagnostics(false) + this.triggerEvent('typesLoading', ['start']) + } + this.typesLoadingCount++ + } + + endTypesBatch() { + this.typesLoadingCount = Math.max(0, this.typesLoadingCount - 1) + if (this.typesLoadingCount === 0) { + this.updateTsCompilerOptions() + this.toggleTsDiagnostics(true) + this.triggerEvent('typesLoading', ['end']) } - // if there's no change, don't do anything - if (input === this.previousInput) { - return + } + + addExtraLibs(libs) { + if (!this.monaco || !libs || libs.length === 0) return + + const tsDefaults = this.monaco.languages.typescript.typescriptDefaults + + libs.forEach(lib => { + if (!tsDefaults.getExtraLibs()[lib.filePath]) { + tsDefaults.addExtraLib(lib.content, lib.filePath) + } + }) + } + + // The conductor, called on every editor content change to parse 'import' statements and trigger the type loading process. + async _onChange (file) { + this.triggerEvent('didChangeFile', [file]) + + if (this.monaco && (file.endsWith('.ts') || file.endsWith('.js') || file.endsWith('.tsx') || file.endsWith('.jsx'))) { + clearTimeout(this.typeLoaderDebounce) + + this.typeLoaderDebounce = setTimeout(async () => { + if (!this.monaco) return + const model = this.monaco.editor.getModel(this.monaco.Uri.parse(file)) + if (!model) return + const code = model.getValue() + + try { + const IMPORT_ANY_RE = /(?:import|export)\s+[^'"]*?from\s*['"]([^'"]+)['"]|import\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g + + const allImports = [...code.matchAll(IMPORT_ANY_RE)] + .map(m => (m[1] || m[2] || m[3] || '').trim()) + .filter(p => p) + + const externalImports = allImports.filter(p => !p.startsWith('.') && !p.startsWith('/') && !p.startsWith('file://')) + const localImports = allImports.filter(p => p.startsWith('.') || p.startsWith('/')) + + const uniqueExternalImports = [...new Set(externalImports)] + const getBasePackage = (p) => p.startsWith('@') ? p.split('/').slice(0, 2).join('/') : p.split('/')[0] + + const newBasePackages = [...new Set(uniqueExternalImports.map(getBasePackage))] + .filter(p => !this.processedPackages.has(p)) + + if (newBasePackages.length > 0) { + this.beginTypesBatch() + + uniqueExternalImports.forEach(pkgImport => this.addShimForPackage(pkgImport)) + this.updateTsCompilerOptions() + + await Promise.all(newBasePackages.map(async (basePackage) => { + this.processedPackages.add(basePackage) + const activeRunnerLibs = await this.call('scriptRunnerBridge', 'getActiveRunnerLibs') + const libInfo = activeRunnerLibs.find(lib => lib.name === basePackage) + const packageToLoad = libInfo ? `${libInfo.name}@${libInfo.version}` : basePackage + + try { + const result = await startTypeLoadingProcess(packageToLoad) + if (result && result.libs && result.libs.length > 0) { + this.addExtraLibs(result.libs) + if (result.subpathMap) { + for (const [subpath, virtualPath] of Object.entries(result.subpathMap)) { + this.tsModuleMappings[subpath] = [virtualPath] + } + } + if (result.mainVirtualPath) { + this.tsModuleMappings[basePackage] = [result.mainVirtualPath.replace('file:///node_modules/', '')] + } + this.tsModuleMappings[`${basePackage}/*`] = [`${basePackage}/*`] + + uniqueExternalImports + .filter(p => getBasePackage(p) === basePackage) + .forEach(p => this.removeShimsForPackage(p)) + } + } catch (e) { + this.processedPackages.delete(basePackage) + console.error(`[DIAGNOSE-DEEP-PASS] Crawler failed for "${basePackage}":`, e) + } + })) + this.endTypesBatch() + } + + if (localImports.length > 0) { + const currentFileType = file.endsWith('.ts') || file.endsWith('.tsx') ? 'typescript' : 'javascript' + const extensions = currentFileType === 'typescript' + ? ['.ts', '.tsx', '.d.ts', '/index.ts', '/index.tsx'] + : ['.js', '.jsx', '/index.js', '/index.jsx'] + + await Promise.all(localImports.map(async (importPath) => { + let resolvedPath = importPath + + if (importPath.startsWith('./') || importPath.startsWith('../')) { + resolvedPath = this.resolveRelativePath(file, importPath) + } else if (importPath.startsWith('/')) { + resolvedPath = importPath.substring(1) + } + + let finalPath = null + + if (await this.call('fileManager', 'exists', resolvedPath) && (await this.call('fileManager', 'isFile', resolvedPath))) { + finalPath = resolvedPath + } else { + for (const ext of extensions) { + const tryPath = resolvedPath + ext + if (await this.call('fileManager', 'exists', tryPath)) { + finalPath = tryPath + break + } + } + } + + if (finalPath) { + try { + const content = await this.call('fileManager', 'readFile', finalPath) + if (content) { + this.emit('addModel', content, currentFileType, finalPath, false) + } + } catch (e) {} // eslint-disable-line no-empty + } + })) + } + + } catch (error) { + console.error('[DIAGNOSE-ONCHANGE] Critical error:', error) + this.endTypesBatch() + } + }, 1500) } + + const currentFile = await this.call('fileManager', 'file') + if (!currentFile || currentFile !== file) return + + const input = this.get(currentFile) + if (!input || input === this.previousInput) return + this.previousInput = input - // fire storage update - // NOTE: save at most once per 5 seconds if (this.saveTimeout) { window.clearTimeout(this.saveTimeout) } @@ -213,67 +457,7 @@ export default class Editor extends Plugin { } async handleTypeScriptDependenciesOf (path, content, readFile, exists) { - const isTsFile = path.endsWith('.ts') || path.endsWith('.tsx') - const isJsFile = path.endsWith('.js') || path.endsWith('.jsx') - - if (isTsFile || isJsFile) { - // extract the import, resolve their content - // and add the imported files to Monaco through the `addModel` - // so Monaco can provide auto completion - const paths = path.split('/') - paths.pop() - const fromPath = paths.join('/') // get current execution context path - const language = isTsFile ? 'typescript' : 'javascript' - - for (const match of content.matchAll(/import\s+.*\s+from\s+(?:"(.*?)"|'(.*?)')/g)) { - let pathDep = match[2] - if (pathDep.startsWith('./') || pathDep.startsWith('../')) pathDep = resolve(fromPath, pathDep) - if (pathDep.startsWith('/')) pathDep = pathDep.substring(1) - - // Try different file extensions if no extension is provided - const extensions = isTsFile ? ['.ts', '.tsx', '.d.ts'] : ['.js', '.jsx'] - let hasExtension = false - for (const ext of extensions) { - if (pathDep.endsWith(ext)) { - hasExtension = true - break - } - } - - if (!hasExtension) { - // Try to find the file with different extensions - for (const ext of extensions) { - const pathWithExt = pathDep + ext - try { - const pathExists = await exists(pathWithExt) - if (pathExists) { - pathDep = pathWithExt - break - } - } catch (e) { - // continue to next extension - } - } - } - - try { - // we can't use the fileManager plugin call directly - // because it's itself called in a plugin context, and that causes a timeout in the plugin stack - const pathExists = await exists(pathDep) - let contentDep = '' - if (pathExists) { - contentDep = await readFile(pathDep) - if (contentDep !== '') { - this.emit('addModel', contentDep, language, pathDep, this.readOnlySessions[path]) - } - } else { - console.log("The file ", pathDep, " can't be found.") - } - } catch (e) { - console.log(e) - } - } - } + await this._onChange(path) } /** diff --git a/apps/remix-ide/src/app/editor/type-fetcher.ts b/apps/remix-ide/src/app/editor/type-fetcher.ts new file mode 100644 index 00000000000..e205938ea15 --- /dev/null +++ b/apps/remix-ide/src/app/editor/type-fetcher.ts @@ -0,0 +1,366 @@ +/** + * [Type Definition] + * Represents a single library file (.d.ts) to be added to the Monaco editor. + * filePath: The virtual path (e.g., 'file:///node_modules/...') + * content: The actual text content of the .d.ts file. + */ +type Library = { filePath: string; content: string } + +/** + * [Type Definition] + * Defines the minimum required fields from a package.json for type loading. + */ +type PackageJson = { + name?: string + version?: string + types?: string + typings?: string + exports?: string | Record +} + +type ResolveResult = { finalUrl: string; content: string } + +/** + * [Type Definition] + * A cache map used to prevent duplicate network requests. + * Key: The Request URL. + * Value: The Promise of the request result. This allows concurrent requests + * for the same URL to share the same Promise (Deduplication). + */ +type FetchCache = Map> + +const CDN_BASE = 'https://cdn.jsdelivr.net/npm/' +const VIRTUAL_BASE = 'file:///node_modules/' + +// Regex to find import/export/require statements. +// Note: Currently optimized for single lines. Use [\s\S]*? if multi-line support is needed. +const IMPORT_ANY_RE = /(?:import|export)\s+[^'"]*?from\s*['"]([^'"]+)['"]|import\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g + +// Regex to find triple-slash directives like /// +const TRIPLE_SLASH_REF_RE = /\/\/\/\s*/g + +// Checks if a path is relative ('./', '../', '/'). +function isRelative(p: string): boolean { + return p.startsWith('./') || p.startsWith('../') || p.startsWith('/') +} + +// Extracts the base package name (e.g., 'viem/chains' -> 'viem', '@scope/pkg/sub' -> '@scope/pkg'). +function normalizeBareSpecifier(p: string): string { + if (!p) return p + if (p.startsWith('@')) return p.split('/').slice(0, 2).join('/') + return p.split('/')[0] +} + +// Generates the @types scoped name (includes logic to prevent infinite recursion). +// e.g., 'react' -> '@types/react', '@scope/pkg' -> '@types/scope__pkg' +function toTypesScopedName(pkg: string): string { + if (pkg.startsWith('@types/')) return pkg + if (pkg.startsWith('@')) return '@types/' + pkg.slice(1).replace('/', '__') + return '@types/' + pkg +} + +// Converts a CDN URL to a virtual file system path used by the Monaco editor. +function toVirtual(url: string): string { + return url.replace(CDN_BASE, VIRTUAL_BASE) +} + +// Removes file extensions (.d.ts, .ts, .js) from a URL. +function stripJsLike(url: string): string { + return url.replace(/\.d\.[mc]?ts$/, '').replace(/\.[mc]?ts$/, '').replace(/\.[mc]?js$/, '') +} + +// Utility function to fetch JSON data. +async function fetchJson(url: string): Promise { + const res = await fetch(url) + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`) + return res.json() +} + +/** + * Guesses a list of potential TypeScript Definition file (.d.ts) paths from a given JS-like file path. + * e.g., 'index.js' -> ['index.d.ts', 'index.ts', 'index/index.d.ts', 'index/index.ts'] + */ +function guessDtsFromJs(jsPath: string): string[] { + const base = stripJsLike(jsPath) + return [`${base}.d.ts`, `${base}.ts`, `${base}/index.d.ts`, `${base}/index.ts`] +} + +/** + * Parses 'exports', 'types', or 'typings' fields in package.json to map subpaths + * to their corresponding entry point URLs. + */ +function buildExportTypeMap(pkgName: string, pkgJson: PackageJson): Record { + const map: Record = {} + const base = `${CDN_BASE}${pkgName}/` + + // Helper: Validates the path and adds it to the map. + const push = (subpath: string, relPath: string | undefined) => { + + if (typeof relPath !== 'string' || !relPath) { + console.warn(`[DIAG-PUSH-ERROR] Invalid path pushed for subpath '${subpath}' in package '${pkgName}'. Type: ${typeof relPath}, Value: ${relPath}`) + return + } + + try { + new URL(relPath, base) + } catch (e) { + console.warn(`[DIAG-PUSH-SKIP] Invalid relative path skipped: ${relPath}`) + return + } + + if (/\.d\.[mc]?ts$/.test(relPath)) { + // If it's already a declaration file, use as is. + map[subpath] = [new URL(relPath, base).href] + } else { + // If it's a JS file, guess the .d.ts location. + map[subpath] = guessDtsFromJs(relPath).map(a => new URL(a, base).href) + } + } + + if (pkgJson.exports) { + const exports = pkgJson.exports as Record + + if (exports.types) { + push('.', exports.types) + return map + } + + if (typeof exports === 'object') { + for (const [subpath, condition] of Object.entries(exports)) { + if (typeof condition === 'object' && condition !== null) { + if (condition.types) { + push(subpath, condition.types) + } else { + let fallbackPath = condition.import || condition.default + + if (typeof fallbackPath === 'object' && fallbackPath !== null) { + if (typeof fallbackPath.default === 'string') { + fallbackPath = fallbackPath.default + } else { + fallbackPath = undefined + } + } + + push(subpath, fallbackPath) + } + } else if (typeof condition === 'string') { + push(subpath, condition) + } + } + } + } + + // Fallback to 'types' or 'typings' if 'exports' didn't yield results. + if (Object.keys(map).length === 0) { + if (pkgJson.types || pkgJson.typings) { + const entryPath = pkgJson.types || pkgJson.typings + if (typeof entryPath === 'string') { + push('.', entryPath) + } + } + + if (Object.keys(map).length === 0) { + // Final fallback: assume index.d.ts at root. + push('.', 'index.d.ts') + } + } + + return map +} + +/** + * [Core Logic] + * Iterates through a list of candidate URLs to fetch file content. + * - Uses 'fetchCache' to prevent duplicate network requests. + * - If a request is already in progress, it reuses the existing Promise. + * - Returns the content of the first successful (200 OK) request. + */ +async function tryFetchOne(urls: string[], fetchCache: FetchCache): Promise { + const uniqueUrls = [...new Set(urls)] + + for (const u of uniqueUrls) { + let fetchPromise = fetchCache.get(u) + + // If not in cache, start a new request + if (!fetchPromise) { + fetchPromise = (async () => { + try { + const res = await fetch(u) + if (res.ok) return await res.text() + return null + } catch (e) { + return null + } + })(); + // Store the Promise itself in the cache to handle race conditions + fetchCache.set(u, fetchPromise) + } + + // Wait for the result (reuses existing promise if available) + const content = await fetchPromise + if (content !== null) { + return { finalUrl: u, content } + } + } + return null +} + +/** + * [Recursive Crawler] + * Parses the content of a type definition file (.d.ts) to find imports/exports and references, + * then recursively loads them. + * - Uses 'visited' set to prevent circular dependency loops. + * - Passes 'fetchCache' down to all recursive calls to optimize network usage. + */ +async function crawl( + entryUrl: string, + pkgName: string, + visited: Set, + fetchCache: FetchCache, + enqueuePackage: (name: string) => void +): Promise { + if (visited.has(entryUrl)) return [] + visited.add(entryUrl) + + const out: Library[] = [] + try { + // If it's strictly a .d.ts, use it. Otherwise, guess the path. + const urlsToTry = /\.d\.[mc]?ts$/.test(entryUrl) + ? [entryUrl] + : guessDtsFromJs(entryUrl) + + // Fetch content using cache + const res = await tryFetchOne(urlsToTry, fetchCache) + if (!res) return [] + + const { finalUrl, content } = res + out.push({ filePath: toVirtual(finalUrl), content }) + + const subPromises: Promise[] = [] + + const crawlNext = (nextUrl: string) => { + // Recurse only if not visited + if (!visited.has(nextUrl)) subPromises.push(crawl(nextUrl, pkgName, visited, fetchCache, enqueuePackage)) + } + + // 1. Parse Triple-slash references (/// ) + for (const m of content.matchAll(TRIPLE_SLASH_REF_RE)) crawlNext(new URL(m[1], finalUrl).href) + + // 2. Parse Import/Export/Require statements + for (const m of content.matchAll(IMPORT_ANY_RE)) { + const spec = (m[1] || m[2] || m[3] || '').trim() + if (!spec) continue + if (isRelative(spec)) crawlNext(new URL(spec, finalUrl).href) // Continue crawling relative paths + else { + // Enqueue external packages to be handled separately in loadPackage + const bare = normalizeBareSpecifier(spec) + if (bare && !bare.startsWith('node:')) enqueuePackage(bare) + } + } + const results = await Promise.all(subPromises) + results.forEach(arr => out.push(...arr)) + } catch (e) {} + return out +} + +/** + * [Main Entry Point] + * The main function called by the Editor. + * Loads type definitions for a specific package and all its dependencies. + */ +export async function startTypeLoadingProcess(packageName: string): Promise<{ mainVirtualPath: string; libs: Library[]; subpathMap: Record } | void> { + const visitedPackages = new Set() + const collected: Library[] = [] + const subpathMap: Record = {} + + // Create a shared request cache for the entire process duration (prevents duplicate 404/200 requests) + const fetchCache: FetchCache = new Map() + + // Inner function: Loads a single package and its dependencies + async function loadPackage(pkgNameToLoad: string) { + if (visitedPackages.has(pkgNameToLoad)) return + visitedPackages.add(pkgNameToLoad) + + let pkgJson: PackageJson + let attemptedTypesFallback = false + + // Loop to handle the @types fallback strategy + while (true) { // eslint-disable-line no-constant-condition + let currentPkgName = pkgNameToLoad + + // If the main package failed, try the @types scoped name + if (attemptedTypesFallback) { + currentPkgName = toTypesScopedName(pkgNameToLoad) + } + + try { + const pkgJsonUrl = new URL('package.json', `${CDN_BASE}${currentPkgName}/`).href + pkgJson = await fetchJson(pkgJsonUrl) + + const exportMap = buildExportTypeMap(currentPkgName, pkgJson) + + // If no types found, attempt fallback to @types + if (Object.keys(exportMap).length === 0) { + if (!attemptedTypesFallback) { + attemptedTypesFallback = true + continue + } else { + return // Give up if @types also fails + } + } + + const pendingDependencies = new Set() + const enqueuePackage = (p: string) => { if (!visitedPackages.has(p)) pendingDependencies.add(p) } + + const crawlPromises: Promise[] = [] + for (const [subpath, urls] of Object.entries(exportMap)) { + const entryPointUrl = urls[0] + if (entryPointUrl) { + const pkgNameWithoutVersion = currentPkgName.replace(/@[\^~]?[\d.\w-]+$/, '') + const virtualPathKey = subpath === '.' ? pkgNameWithoutVersion : `${pkgNameWithoutVersion}/${subpath.replace('./', '')}` + + subpathMap[virtualPathKey] = entryPointUrl.replace(CDN_BASE, '') + // Start crawling (passing fetchCache) + crawlPromises.push(crawl(entryPointUrl, currentPkgName, new Set(), fetchCache, enqueuePackage)) + } + } + + const libsArrays = await Promise.all(crawlPromises) + let totalCollectedFiles = 0 + libsArrays.forEach(libs => { + collected.push(...libs) + totalCollectedFiles += libs.length + }) + + // If package.json exists but no .d.ts files were found, try @types fallback + if (totalCollectedFiles === 0 && !attemptedTypesFallback) { + attemptedTypesFallback = true + continue + } + + // Load discovered dependencies + if (pendingDependencies.size > 0) { + await Promise.all(Array.from(pendingDependencies).map(loadPackage)) + } + + return + + } catch (e) { + // If 404 occurs, try @types fallback + if (e && e.message && e.message.includes('404') && !attemptedTypesFallback) { + attemptedTypesFallback = true + continue + } + console.error(`- Fatal error or already tried @types for '${currentPkgName}':`, e.message) + return + } + } + } + + await loadPackage(packageName) + + const mainVirtualPath = subpathMap[packageName] ? `${VIRTUAL_BASE}${subpathMap[packageName]}` : '' + const finalPackages = [...new Set(collected.map(lib => normalizeBareSpecifier(lib.filePath.replace(VIRTUAL_BASE, ''))))] + + return { mainVirtualPath, libs: collected, subpathMap } +} \ No newline at end of file diff --git a/apps/remix-ide/src/app/plugins/script-runner-bridge.tsx b/apps/remix-ide/src/app/plugins/script-runner-bridge.tsx index 961afb16fc8..ceebad13461 100644 --- a/apps/remix-ide/src/app/plugins/script-runner-bridge.tsx +++ b/apps/remix-ide/src/app/plugins/script-runner-bridge.tsx @@ -13,8 +13,8 @@ import { ScriptRunnerUIPlugin } from '../tabs/script-runner-ui' const profile = { name: 'scriptRunnerBridge', displayName: 'Script configuration', - methods: ['execute', 'getConfigurations', 'selectScriptRunner'], - events: ['log', 'info', 'warn', 'error'], + methods: ['execute', 'getConfigurations', 'selectScriptRunner', 'getActiveRunnerLibs'], + events: ['log', 'info', 'warn', 'error', 'runnerChanged'], icon: 'assets/img/solid-gear-circle-play.svg', description: 'Configure the dependencies for running scripts.', kind: '', @@ -28,6 +28,85 @@ const configFileName = 'remix.config.json' let baseUrl = 'https://remix-project-org.github.io/script-runner-generator' const customBuildUrl = 'http://localhost:4000/build' // this will be used when the server is ready +/** + * Transforms the provided script content to make it executable in a browser environment. + * * Key Transformation Logic: + * 1. Hybrid Import Handling: + * - Relative imports (starting with `.` or `/`) and libraries listed in `builtInDependencies` + * are preserved as standard static ES imports (hoisted to the top). + * - External NPM packages are converted into dynamic `await import(...)` calls fetching from `cdn.jsdelivr.net`. + * * 2. Multi-line Support: + * - Uses an enhanced Regex (`[\s\S]*?`) to correctly parse import statements that span multiple lines. + * * 3. Async Wrapper: + * - Wraps the main execution logic (excluding static imports) in an `async IIFE` + * to enable top-level await behavior for the dynamic imports. + * * 4. Syntax Adjustments: + * - Handles various import styles: Destructuring (`{ a }`), Namespace (`* as a`), and Default (`a`). + * - Removes `export` keywords to prevent syntax errors within the IIFE context. + * + * @param scriptContent - The original source code of the script to be transformed. + * @param builtInDependencies - An array of package names that are pre-bundled or available in the runtime environment + * (e.g., ['chai', 'web3']) and should not be fetched from the CDN. + * @returns The transformed script string, ready for runtime evaluation. + */ +function transformScriptForRuntime(scriptContent: string, builtInDependencies: string[] = []): string { + const dynamicImportHelper = `const dynamicImport = (p) => new Function(\`return import('https://cdn.jsdelivr.net/npm/\${p}/+esm')\`)();\n` + const importRegex = /import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g + + const staticImports = [] + const dynamicImports = [] + + const scriptBody = scriptContent.replace(importRegex, (match, importClause, packageName) => { + if (packageName.startsWith('.') || packageName.startsWith('/')) { + staticImports.push(match) + return '' + } + + if (builtInDependencies.includes(packageName)) { + staticImports.push(match) + return '' + } + + if (packageName === 'hardhat') { + staticImports.push(match) + return '' + } + + dynamicImports.push({ importClause, packageName }) + return '' + }) + + let finalScript = '' + + if (staticImports.length > 0) { + finalScript += staticImports.join('\n') + '\n\n' + } + + finalScript += `${dynamicImportHelper}\n(async () => {\n try {\n` + + if (dynamicImports.length > 0) { + const dynamicTransforms = [] + for (const info of dynamicImports) { + if (info.importClause.startsWith('{')) { + dynamicTransforms.push(` const ${info.importClause} = await dynamicImport("${info.packageName}");`) + } else if (info.importClause.startsWith('* as')) { + const alias = info.importClause.split('as ')[1] + dynamicTransforms.push(` const ${alias} = await dynamicImport("${info.packageName}");`) + } else { + dynamicTransforms.push(` const ${info.importClause} = (await dynamicImport("${info.packageName}")).default || await dynamicImport("${info.packageName}");`) + } + } + finalScript += dynamicTransforms.join('\n') + '\n\n' + } + + const finalScriptBody = scriptBody.replace(/^export\s+/gm, '') + finalScript += finalScriptBody + + finalScript += `\n } catch (e) { console.error('Error executing script:', e); }\n})();` + + return finalScript +} + export class ScriptRunnerBridgePlugin extends Plugin { engine: Engine dispatch: React.Dispatch = () => {} @@ -61,7 +140,6 @@ export class ScriptRunnerBridgePlugin extends Plugin { await this.loadConfigurations() const ui: ScriptRunnerUIPlugin = new ScriptRunnerUIPlugin(this) this.engine.register(ui) - } setListeners() { @@ -113,6 +191,13 @@ export class ScriptRunnerBridgePlugin extends Plugin { }) } + public getActiveRunnerLibs() { + if (this.activeConfig && this.activeConfig.dependencies) { + return this.activeConfig.dependencies + } + return [] + } + public getConfigurations() { return this.configurations } @@ -122,7 +207,10 @@ export class ScriptRunnerBridgePlugin extends Plugin { } async selectScriptRunner(config: ProjectConfiguration) { - if (await this.loadScriptRunner(config)) await this.saveCustomConfig(this.customConfig) + if (await this.loadScriptRunner(config)) { + await this.saveCustomConfig(this.customConfig) + this.emit('runnerChanged', config) + } } async loadScriptRunner(config: ProjectConfiguration): Promise { @@ -197,7 +285,12 @@ export class ScriptRunnerBridgePlugin extends Plugin { } try { this.setIsLoading(this.activeConfig.name, true) - await this.call(`${this.scriptRunnerProfileName}${this.activeConfig.name}`, 'execute', script, filePath) + // Transforms the script into an executable format using the function defined above. + const builtInDependencies = this.activeConfig.dependencies ? this.activeConfig.dependencies.map(dep => dep.name) : [] + const transformedScript = transformScriptForRuntime(script, builtInDependencies) + + await this.call(`${this.scriptRunnerProfileName}${this.activeConfig.name}`, 'execute', transformedScript, filePath) + } catch (e) { console.error('Error executing script', e) } @@ -235,7 +328,6 @@ export class ScriptRunnerBridgePlugin extends Plugin { } async dependencyError(data: any) { - console.log('Script runner dependency error: ', data) let message = `Error loading dependencies: ` if (isArray(data.data)) { data.data.forEach((data: any) => { @@ -371,7 +463,6 @@ export class ScriptRunnerBridgePlugin extends Plugin { console.log('Error status:', error.response.status) console.log('Error data:', error.response.data) // This should give you the output being sent console.log('Error headers:', error.response.headers) - if (error.response.data.error) { if (isArray(error.response.data.error)) { const message = `${error.response.data.error[0]}` diff --git a/libs/remix-ui/editor/src/lib/providers/tsCompletionProvider.ts b/libs/remix-ui/editor/src/lib/providers/tsCompletionProvider.ts new file mode 100644 index 00000000000..99d1f9ce1a5 --- /dev/null +++ b/libs/remix-ui/editor/src/lib/providers/tsCompletionProvider.ts @@ -0,0 +1,92 @@ +import { monacoTypes } from '@remix-ui/editor' + +interface TsCompletionInfo { + entries: { + name: string + kind: string + }[] +} + +// [1/4] This class provides TypeScript/JavaScript autocompletion features to the Monaco editor. +export class RemixTSCompletionProvider implements monacoTypes.languages.CompletionItemProvider { + monaco: any + + constructor(monaco: any) { + this.monaco = monaco + } + + // Defines trigger characters for autocompletion (e.g., suggesting object members after typing '.'). + triggerCharacters = ['.', '"', "'", '/', '@'] + + // The main function called by the Monaco editor as the user types. + async provideCompletionItems(model: monacoTypes.editor.ITextModel, position: monacoTypes.Position, context: monacoTypes.languages.CompletionContext): Promise { + const word = model.getWordUntilPosition(position) + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn + } + + try { + // [4/4] It fetches type information loaded by the editor plugin ('editor.ts') via 'type-fetcher.ts', + // using Monaco's built-in TypeScript Worker to generate an autocompletion list. + const worker = await this.monaco.languages.typescript.getTypeScriptWorker() + const client = await worker(model.uri) + const completions: TsCompletionInfo = await client.getCompletionsAtPosition( + model.uri.toString(), + model.getOffsetAt(position) + ) + + if (!completions || !completions.entries) { + return { suggestions: []} + } + + // Converts the suggestion list from the TypeScript Worker into a format that the Monaco editor can understand. + const suggestions = completions.entries.map(entry => { + return { + label: entry.name, + kind: this.mapTsCompletionKindToMonaco(entry.kind), + insertText: entry.name, + range: range + } + }) + + return { suggestions } + } catch (error) { + console.error('[TSCompletionProvider] Error fetching completions:', error) + return { suggestions: []} + } + } + + // Maps TypeScript's 'CompletionItemKind' string to Monaco's numeric Enum value. + private mapTsCompletionKindToMonaco(kind: string): monacoTypes.languages.CompletionItemKind { + const { CompletionItemKind } = this.monaco.languages + switch (kind) { + case 'method': + case 'memberFunction': + return CompletionItemKind.Method + case 'function': + return CompletionItemKind.Function + case 'property': + case 'memberVariable': + return CompletionItemKind.Property + case 'class': + return CompletionItemKind.Class + case 'interface': + return CompletionItemKind.Interface + case 'keyword': + return CompletionItemKind.Keyword + case 'variable': + return CompletionItemKind.Variable + case 'constructor': + return CompletionItemKind.Constructor + case 'enum': + return CompletionItemKind.Enum + case 'module': + return CompletionItemKind.Module + default: + return CompletionItemKind.Text + } + } +} \ No newline at end of file diff --git a/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx b/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx index 1e9ade481c2..14265d1ae64 100644 --- a/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx +++ b/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx @@ -31,6 +31,8 @@ import { noirLanguageConfig, noirTokensProvider } from './syntaxes/noir' import type { IPosition, IRange } from 'monaco-editor' import { GenerationParams } from '@remix/remix-ai-core'; import { RemixInLineCompletionProvider } from './providers/inlineCompletionProvider' +import { RemixTSCompletionProvider } from './providers/tsCompletionProvider' +const _paq = (window._paq = window._paq || []) // eslint-disable-line // Key for localStorage const HIDE_PASTE_WARNING_KEY = 'remixide.hide_paste_warning'; @@ -156,6 +158,7 @@ export interface EditorUIProps { } plugin: PluginType editorAPI: EditorAPIType + setMonaco: (monaco: Monaco) => void } const contextMenuEvent = new EventManager() export const EditorUI = (props: EditorUIProps) => { @@ -1162,6 +1165,9 @@ export const EditorUI = (props: EditorUIProps) => { const editorService = editor._codeEditorService const openEditorBase = editorService.openCodeEditor.bind(editorService) editorService.openCodeEditor = async (input, source) => { + if (input && input.resource && input.resource.path.includes('__shims__')) { + return openEditorBase(input, source) + } const result = await openEditorBase(input, source) if (input && input.resource && input.resource.path) { try { @@ -1187,6 +1193,7 @@ export const EditorUI = (props: EditorUIProps) => { function handleEditorWillMount(monaco) { monacoRef.current = monaco + props.setMonaco(monaco) // Register a new language monacoRef.current.languages.register({ id: 'remix-solidity' }) monacoRef.current.languages.register({ id: 'remix-cairo' }) @@ -1199,45 +1206,12 @@ export const EditorUI = (props: EditorUIProps) => { // Allow JSON schema requests monacoRef.current.languages.json.jsonDefaults.setDiagnosticsOptions({ enableSchemaRequest: true }) + monacoRef.current.languages.registerCompletionItemProvider('typescript', new RemixTSCompletionProvider(monaco)) + monacoRef.current.languages.registerCompletionItemProvider('javascript', new RemixTSCompletionProvider(monaco)) + // hide the module resolution error. We have to remove this when we know how to properly resolve imports. monacoRef.current.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ diagnosticCodesToIgnore: [2792]}) - // Configure TypeScript compiler options for JSX/TSX support - monacoRef.current.languages.typescript.typescriptDefaults.setCompilerOptions({ - jsx: monacoRef.current.languages.typescript.JsxEmit.React, - jsxFactory: 'React.createElement', - reactNamespace: 'React', - allowNonTsExtensions: true, - allowJs: true, - target: monacoRef.current.languages.typescript.ScriptTarget.Latest, - moduleResolution: monacoRef.current.languages.typescript.ModuleResolutionKind.NodeJs, - module: monacoRef.current.languages.typescript.ModuleKind.ESNext, - noEmit: true, - esModuleInterop: true, - allowSyntheticDefaultImports: true, - skipLibCheck: true, - resolveJsonModule: true, - isolatedModules: true, - }) - - // Configure JavaScript compiler options for JSX support - monacoRef.current.languages.typescript.javascriptDefaults.setCompilerOptions({ - jsx: monacoRef.current.languages.typescript.JsxEmit.React, - jsxFactory: 'React.createElement', - reactNamespace: 'React', - allowNonTsExtensions: true, - target: monacoRef.current.languages.typescript.ScriptTarget.Latest, - moduleResolution: monacoRef.current.languages.typescript.ModuleResolutionKind.NodeJs, - module: monacoRef.current.languages.typescript.ModuleKind.ESNext, - noEmit: true, - esModuleInterop: true, - allowSyntheticDefaultImports: true, - skipLibCheck: true, - resolveJsonModule: true, - isolatedModules: true, - checkJs: false, - }) - // Enable JSX diagnostics for JavaScript monacoRef.current.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ noSemanticValidation: false,