diff --git a/apps/remix-ide/src/app/editor/editor.js b/apps/remix-ide/src/app/editor/editor.js index c89fd8a1d07..121026d9211 100644 --- a/apps/remix-ide/src/app/editor/editor.js +++ b/apps/remix-ide/src/app/editor/editor.js @@ -6,6 +6,8 @@ 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 = { @@ -71,12 +73,21 @@ export default class Editor extends Plugin { this.api = {} this.dispatch = null this.ref = null + + this.monaco = null + this.typeLoaderDebounce = null + + this.tsModuleMappings = {} } setDispatch (dispatch) { this.dispatch = dispatch } + setMonaco (monaco) { + this.monaco = monaco + } + updateComponent(state) { return this.setMonaco(monaco)} /> } @@ -128,6 +140,18 @@ export default class Editor extends Plugin { async onActivation () { this.activated = true + this.on('editor', 'editorMounted', () => { + if (!this.monaco) return + const tsDefaults = this.monaco.languages.typescript.typescriptDefaults + + tsDefaults.setCompilerOptions({ + moduleResolution: this.monaco.languages.typescript.ModuleResolutionKind.NodeJs, + target: this.monaco.languages.typescript.ScriptTarget.ES2020, + allowNonTsExtensions: true, + baseUrl: 'file:///node_modules/', + paths: {} + }) + }) this.on('sidePanel', 'focusChanged', (name) => { this.keepDecorationsFor(name, 'sourceAnnotationsPerFile') this.keepDecorationsFor(name, 'markerPerFile') @@ -156,8 +180,88 @@ export default class Editor extends Plugin { this.off('sidePanel', 'pluginDisabled') } + updateTsCompilerOptions() { + console.log('[Module Mapper] Updating TS compiler options with new paths:', this.tsModuleMappings) + const tsDefaults = this.monaco.languages.typescript.typescriptDefaults + const oldOptions = tsDefaults.getCompilerOptions() + + const newOptions = { + ...oldOptions, + baseUrl: 'file:///node_modules/', + paths: this.tsModuleMappings + } + + console.log('[DEBUG 3] Updating TS compiler with new options:', JSON.stringify(newOptions, null, 2)) + tsDefaults.setCompilerOptions(newOptions) + + setTimeout(() => { + const allLibs = tsDefaults.getExtraLibs() + console.log('[DEBUG 4] Final check - Monaco extraLibs state:', Object.keys(allLibs).length, 'libs loaded.') + }, 2000) + } + async _onChange (file) { this.triggerEvent('didChangeFile', [file]) + + if (this.monaco && (file.endsWith('.ts') || file.endsWith('.js'))) { + 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 extractPackageName = (importPath) => { + if (importPath.startsWith('@')) { + const parts = importPath.split('/') + return `${parts[0]}/${parts[1]}` + } + return importPath.split('/')[0] + } + + const rawImports = [...code.matchAll(/from\s+['"]((?![./]).*?)['"]/g)].map(match => match[1]) + + const uniquePackages = [...new Set(rawImports.map(extractPackageName))] + console.log('[DEBUG 1] Extracted Package Names:', uniquePackages) + const newPackages = uniquePackages.filter(p => !this.tsModuleMappings[p]) + if (newPackages.length === 0) return + + console.log('[Module Mapper] New packages detected:', newPackages) + + let newPathsFound = false + const promises = newPackages.map(async (pkg) => { + try { + const result = await startTypeLoadingProcess(pkg, this.monaco) + + if (result && result.virtualPath) { + // this.tsModuleMappings[pkg] = [`${pkg}/`] + // this.tsModuleMappings[`${pkg}/*`] = [`${pkg}/*`] + const typeFileRelativePath = result.virtualPath.replace('file:///node_modules/', '') + + this.tsModuleMappings[pkg] = [typeFileRelativePath] + this.tsModuleMappings[`${pkg}/*`] = [`${pkg}/*`] + + newPathsFound = true + } + } catch (error) { + console.error(`[Module Mapper] Failed to process types for ${pkg}`, error) + } + }) + + await Promise.all(promises) + + if (newPathsFound) { + setTimeout(() => this.updateTsCompilerOptions(), 1000) + } + + } catch (error) { + console.error('[Type Loader] Error during type loading process:', error) + } + }, 1500) + } + const currentFile = await this.call('fileManager', 'file') if (!currentFile) { return @@ -232,7 +336,7 @@ export default class Editor extends Plugin { this.emit('addModel', contentDep, 'typescript', pathDep, this.readOnlySessions[path]) } } else { - console.log("The file ", pathDep, " can't be found.") + // console.log("The file ", pathDep, " can't be found.") } } catch (e) { console.log(e) 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..0f2e0c69fd2 --- /dev/null +++ b/apps/remix-ide/src/app/editor/type-fetcher.ts @@ -0,0 +1,184 @@ +import { Monaco } from '@monaco-editor/react' + +const processedPackages = new Set() +const loadedLibs = new Set() +const NODE_BUILTINS = new Set(['util', 'events', 'buffer', 'stream', 'path', 'fs', 'os', 'crypto', 'http', 'https', 'url', 'zlib']) + +class NoTypesError extends Error { + constructor(message: string) { + super(message) + this.name = 'NoTypesError' + } +} + +function getTypesPackageName(packageName: string): string { + if (packageName.startsWith('@')) { + const mangledName = packageName.substring(1).replace('/', '__'); + return `@types/${mangledName}` + } + return `@types/${packageName}` +} + +export async function startTypeLoadingProcess(packageName: string, monaco: Monaco): Promise<{ virtualPath: string; hasExports: boolean } | void> { + if (NODE_BUILTINS.has(packageName)) { + packageName = '@types/node' + } + + if (processedPackages.has(packageName)) return + processedPackages.add(packageName) + + console.log(`[Type Fetcher] Starting type loading process for "${packageName}"...`) + + try { + return await loadTypesInBackground(packageName, monaco) + } catch (error) { + if (error.message.includes('No type definition') || error.message.includes('Failed to fetch any type definition')) { + console.warn(`[Type Fetcher] No types found for "${packageName}". Reason:`, error.message) + const typesPackageName = getTypesPackageName(packageName) + if (packageName === typesPackageName) return + + console.log(`[Type Fetcher] Trying ${typesPackageName} as a fallback...`) + return startTypeLoadingProcess(typesPackageName, monaco) + } else { + console.error(`[Type Fetcher] Loading process failed for "${packageName}" and will not fallback to @types. Error:`, error.message) + } + } +} + +async function resolveAndFetchDts(resolvedUrl: string): Promise<{ finalUrl: string; content: string }> { + const urlWithoutTrailingSlash = resolvedUrl.endsWith('/') ? resolvedUrl.slice(0, -1) : resolvedUrl + const attempts: string[] = [] + + if (/\.(m|c)?js$/.test(urlWithoutTrailingSlash)) { + attempts.push(urlWithoutTrailingSlash.replace(/\.(m|c)?js$/, '.d.ts')) + attempts.push(urlWithoutTrailingSlash.replace(/\.(m|c)?js$/, '.d.mts')) + attempts.push(urlWithoutTrailingSlash.replace(/\.(m|c)?js$/, '.d.cts')) + } else if (!/\.d\.(m|c)?ts$/.test(urlWithoutTrailingSlash)) { + attempts.push(`${urlWithoutTrailingSlash}.d.ts`) + attempts.push(`${urlWithoutTrailingSlash}.d.mts`) + attempts.push(`${urlWithoutTrailingSlash}.d.cts`) + attempts.push(`${urlWithoutTrailingSlash}/index.d.ts`) + attempts.push(`${urlWithoutTrailingSlash}/index.d.mts`) + attempts.push(`${urlWithoutTrailingSlash}/index.d.cts`) + } else { + attempts.push(urlWithoutTrailingSlash) + } + + for (const url of attempts) { + try { + const response = await fetch(url) + if (response.ok) { + return { finalUrl: url, content: await response.text() } + } + } catch (e) {} + } + throw new Error(`Could not resolve DTS file for ${resolvedUrl}`) +} + +async function loadTypesInBackground(packageName: string, monaco: Monaco): Promise<{ virtualPath: string; hasExports: boolean } | void> { + const baseUrl = `https://cdn.jsdelivr.net/npm/${packageName}/` + const packageJsonUrl = `${baseUrl}package.json` + const response = await fetch(packageJsonUrl) + + if (!response.ok) throw new Error(`Failed to fetch package.json for "${packageName}"`) + + const packageJson = await response.json() + + console.log(`[Type Fetcher] Fetched package.json for "${packageName}", version: ${packageJson.version}`) + addLibToMonaco(`file:///node_modules/${packageName}/package.json`, JSON.stringify(packageJson), monaco) + + const typePathsToFetch = new Set() + + const hasExports = typeof packageJson.exports === 'object' && packageJson.exports !== null + console.log(`[Type Fetcher DBG] 'exports' field detected: ${hasExports}`) + + if (hasExports) { + for (const key in packageJson.exports) { + if (key.includes('*') || key.endsWith('package.json')) continue + + const entry = packageJson.exports[key] + + let typePath: string | null = null + + if (typeof entry === 'string') { + if (!entry.endsWith('.json')) { + typePath = entry.replace(/\.(m|c)?js$/, '.d.ts') + } + } else if (typeof entry === 'object' && entry !== null) { + if (typeof entry.types === 'string') { + typePath = entry.types + } + else if (typeof entry.import === 'string') { + typePath = entry.import.replace(/\.(m|c)?js$/, '.d.ts') + } else if (typeof entry.default === 'string') { + typePath = entry.default.replace(/\.(m|c)?js$/, '.d.ts') + } + } + + if (typePath) { + console.log(`[Type Fetcher DBG] Found type path for exports['${key}']: ${typePath}`) + typePathsToFetch.add(typePath) + } + } + } + + const mainTypePath = packageJson.types || packageJson.typings + console.log(`[Type Fetcher DBG] Top-level 'types' field: ${mainTypePath}`) + if (typeof mainTypePath === 'string') { + typePathsToFetch.add(mainTypePath) + } + + console.log(`[Type Fetcher DBG] Total type paths found: ${typePathsToFetch.size}`) + if (typePathsToFetch.size === 0) { + const mainField = packageJson.main + if (typeof mainField === 'string') { + console.log(`[Type Fetcher DBG] Inferring from 'main' field: ${mainField}`) + typePathsToFetch.add(mainField.replace(/\.(m|c)?js$/, '.d.ts')) + } + + if (typePathsToFetch.size === 0) { + throw new NoTypesError(`No type definition entry found in package.json.`) + } + } + + let mainVirtualPath = '' + for (const relativePath of typePathsToFetch) { + let cleanPath = relativePath + if (!cleanPath.startsWith('./')) cleanPath = './' + cleanPath + + const fileUrl = new URL(cleanPath, baseUrl).href + try { + const { finalUrl, content } = await resolveAndFetchDts(fileUrl) + const virtualPath = finalUrl.replace('https://cdn.jsdelivr.net/npm', 'file:///node_modules') + addLibToMonaco(virtualPath, content, monaco) + if (!mainVirtualPath) mainVirtualPath = virtualPath + } catch (error) { + console.warn(`[Type Fetcher] Could not fetch sub-type file: ${fileUrl}`, error) + } + } + + if (!mainVirtualPath) throw new Error('Failed to fetch any type definition files.') + + console.log(`[Type Fetcher] Completed fetching all type definitions for ${packageName}.`) + + if (packageJson.dependencies) { + console.log(`[Type Fetcher] Found ${Object.keys(packageJson.dependencies).length} dependencies for ${packageName}. Fetching them...`) + const depPromises = Object.keys(packageJson.dependencies).map(dep => { + try { + return startTypeLoadingProcess(dep, monaco) + } catch(e) { + console.warn(`[Type Fetcher] Failed to start loading types for dependency: ${dep}`, e.message) + return Promise.resolve() + } + }) + await Promise.all(depPromises) + } + + return { virtualPath: mainVirtualPath, hasExports } +} + +function addLibToMonaco(filePath: string, content: string, monaco: Monaco) { + if (loadedLibs.has(filePath)) return + monaco.languages.typescript.typescriptDefaults.addExtraLib(content, filePath) + loadedLibs.add(filePath) +} \ 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..55b05e168a8 100644 --- a/apps/remix-ide/src/app/plugins/script-runner-bridge.tsx +++ b/apps/remix-ide/src/app/plugins/script-runner-bridge.tsx @@ -28,6 +28,36 @@ 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 standard import statements into dynamic import statements for runtime execution. + * @param {string} scriptContent The original script content. + * @returns {string} The transformed script content. + */ +function transformScriptForRuntime(scriptContent: string): string { + // 1. dynamicImport 헬퍼 함수를 스크립트 맨 위에 주입 + const dynamicImportHelper = `const dynamicImport = (p) => new Function(\`return import('https://cdn.jsdelivr.net/npm/\${p}/+esm')\`)();\n`; + + // 2. 다양한 import 구문을 변환 + // 'import { ... } from "package"' 구문 + let transformed = scriptContent.replace( + /import\s+({[\s\S]*?})\s+from\s+['"]([^'"]+)['"]/g, + 'const $1 = await dynamicImport("$2");' + ); + // 'import Default from "package"' 구문 + transformed = transformed.replace( + /import\s+([\w\d_$]+)\s+from\s+['"]([^'"]+)['"]/g, + 'const $1 = (await dynamicImport("$2")).default;' + ); + // 'import * as name from "package"' 구문 + transformed = transformed.replace( + /import\s+\*\s+as\s+([\w\d_$]+)\s+from\s+['"]([^'"]+)['"]/g, + 'const $1 = await dynamicImport("$2");' + ); + + // 3. 모든 코드를 async IIFE로 감싸서 top-level await 문제 해결 + return `${dynamicImportHelper}\n(async () => {\n try {\n${transformed}\n } catch (e) { console.error('Error executing script:', e); }\n})();`; +} + export class ScriptRunnerBridgePlugin extends Plugin { engine: Engine dispatch: React.Dispatch = () => {} @@ -197,7 +227,15 @@ export class ScriptRunnerBridgePlugin extends Plugin { } try { this.setIsLoading(this.activeConfig.name, true) - await this.call(`${this.scriptRunnerProfileName}${this.activeConfig.name}`, 'execute', script, filePath) + const transformedScript = transformScriptForRuntime(script); + + console.log('--- [ScriptRunner] Original Script ---'); + console.log(script); + console.log('--- [ScriptRunner] Transformed Script for Runtime ---'); + console.log(transformedScript); + + await this.call(`${this.scriptRunnerProfileName}${this.activeConfig.name}`, 'execute',transformedScript, filePath) + } catch (e) { console.error('Error executing script', e) } 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..0e879da5df6 --- /dev/null +++ b/libs/remix-ui/editor/src/lib/providers/tsCompletionProvider.ts @@ -0,0 +1,85 @@ +import { monacoTypes } from '@remix-ui/editor' + +interface TsCompletionInfo { + entries: { + name: string + kind: string + }[] +} + +export class RemixTSCompletionProvider implements monacoTypes.languages.CompletionItemProvider { + monaco: any + + constructor(monaco: any) { + this.monaco = monaco + } + + triggerCharacters = ['.', '"', "'", '/', '@'] + + 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 { + 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: []} + } + + 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: []} + } + } + + 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 aa913e823d4..036c037325e 100644 --- a/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx +++ b/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx @@ -28,6 +28,7 @@ import { noirLanguageConfig, noirTokensProvider } from './syntaxes/noir' import { 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 || []) // Key for localStorage @@ -154,6 +155,7 @@ export interface EditorUIProps { } plugin: PluginType editorAPI: EditorAPIType + setMonaco: (monaco: Monaco) => void } const contextMenuEvent = new EventManager() export const EditorUI = (props: EditorUIProps) => { @@ -1152,6 +1154,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' }) @@ -1164,9 +1167,11 @@ 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]}) - // Register a tokens provider for the language monacoRef.current.languages.setMonarchTokensProvider('remix-solidity', solidityTokensProvider as any) monacoRef.current.languages.setLanguageConfiguration('remix-solidity', solidityLanguageConfig as any) diff --git a/package.json b/package.json index ecf828ee24e..e27e40afe3d 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,9 @@ "@reown/appkit": "^1.7.4", "@reown/appkit-adapter-ethers": "^1.7.4", "@ricarso/react-image-magnifiers": "^1.9.0", + "@types/acorn": "^6.0.4", "@types/nightwatch": "^2.3.1", + "acorn": "^8.15.0", "ansi-gray": "^0.1.1", "assert": "^2.1.0", "async": "^2.6.2", diff --git a/yarn.lock b/yarn.lock index 4974ad25681..4003c39cffb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7248,6 +7248,13 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== +"@types/acorn@^6.0.4": + version "6.0.4" + resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-6.0.4.tgz#b1a652a373d0cace52dace608fced14f58e9c4a9" + integrity sha512-DafqcBAjbOOmgqIx3EF9EAdBKAKgspv00aQVIW3fVQ0TXo5ZPBeSRey1SboVAUzjw8Ucm7cd1gtTSlosYoEQLA== + dependencies: + acorn "*" + "@types/aria-query@^4.2.0": version "4.2.2" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc" @@ -8718,6 +8725,11 @@ acorn-walk@^8.0.0, acorn-walk@^8.1.1: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== +acorn@*, acorn@^8.15.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + "acorn@>= 2.5.2 <= 5.7.5": version "5.7.4" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e"