diff --git a/deno.jsonc b/deno.jsonc index a2b97781..be98ada0 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -13,7 +13,8 @@ }, "exclude": [ ".coverage/", - "tests/denops/testdata/no_check/" + "tests/denops/testdata/no_check/", + "tests/denops/testdata/with_import_map/" ], // NOTE: Import maps should only be used from test modules. "imports": { diff --git a/denops/@denops-private/plugin.ts b/denops/@denops-private/plugin.ts new file mode 100644 index 00000000..8b7b4c7c --- /dev/null +++ b/denops/@denops-private/plugin.ts @@ -0,0 +1,183 @@ +import type { Denops, Entrypoint } from "jsr:@denops/core@^7.0.0"; +import { + type ImportMap, + ImportMapImporter, + loadImportMap, +} from "jsr:@lambdalisue/import-map-importer@^0.3.1"; +import { toFileUrl } from "jsr:@std/path@^1.0.2/to-file-url"; +import { fromFileUrl } from "jsr:@std/path@^1.0.2/from-file-url"; +import { join } from "jsr:@std/path@^1.0.2/join"; +import { dirname } from "jsr:@std/path@^1.0.2/dirname"; + +type PluginModule = { + main: Entrypoint; +}; + +export class Plugin { + #denops: Denops; + #loadedWaiter: Promise; + #unloadedWaiter?: Promise; + #disposable: AsyncDisposable = voidAsyncDisposable; + + readonly name: string; + readonly script: string; + + constructor(denops: Denops, name: string, script: string) { + this.#denops = denops; + this.name = name; + this.script = resolveScriptUrl(script); + this.#loadedWaiter = this.#load(); + } + + waitLoaded(): Promise { + return this.#loadedWaiter; + } + + async #load(): Promise { + await emit(this.#denops, `DenopsSystemPluginPre:${this.name}`); + try { + const mod: PluginModule = await importPlugin(this.script); + this.#disposable = await mod.main(this.#denops) ?? voidAsyncDisposable; + } catch (e) { + // Show a warning message when Deno module cache issue is detected + // https://github.com/vim-denops/denops.vim/issues/358 + if (isDenoCacheIssueError(e)) { + console.warn("*".repeat(80)); + console.warn(`Deno module cache issue is detected.`); + console.warn( + `Execute 'call denops#cache#update(#{reload: v:true})' and restart Vim/Neovim.`, + ); + console.warn( + `See https://github.com/vim-denops/denops.vim/issues/358 for more detail.`, + ); + console.warn("*".repeat(80)); + } + console.error(`Failed to load plugin '${this.name}': ${e}`); + await emit(this.#denops, `DenopsSystemPluginFail:${this.name}`); + throw e; + } + await emit(this.#denops, `DenopsSystemPluginPost:${this.name}`); + } + + unload(): Promise { + if (!this.#unloadedWaiter) { + this.#unloadedWaiter = this.#unload(); + } + return this.#unloadedWaiter; + } + + async #unload(): Promise { + try { + // Wait for the load to complete to make the events atomically. + await this.#loadedWaiter; + } catch { + // Load failed, do nothing + return; + } + const disposable = this.#disposable; + this.#disposable = voidAsyncDisposable; + await emit(this.#denops, `DenopsSystemPluginUnloadPre:${this.name}`); + try { + await disposable[Symbol.asyncDispose](); + } catch (e) { + console.error(`Failed to unload plugin '${this.name}': ${e}`); + await emit(this.#denops, `DenopsSystemPluginUnloadFail:${this.name}`); + return; + } + await emit(this.#denops, `DenopsSystemPluginUnloadPost:${this.name}`); + } + + async call(fn: string, ...args: unknown[]): Promise { + try { + return await this.#denops.dispatcher[fn](...args); + } catch (err) { + const errMsg = err instanceof Error + ? err.stack ?? err.message // Prefer 'stack' if available + : String(err); + throw new Error( + `Failed to call '${fn}' API in '${this.name}': ${errMsg}`, + ); + } + } +} + +const voidAsyncDisposable = { + [Symbol.asyncDispose]: () => Promise.resolve(), +} as const satisfies AsyncDisposable; + +const loadedScripts = new Set(); + +function createScriptSuffix(script: string): string { + // Import module with fragment so that reload works properly + // https://github.com/vim-denops/denops.vim/issues/227 + const suffix = loadedScripts.has(script) ? `#${performance.now()}` : ""; + loadedScripts.add(script); + return suffix; +} + +/** NOTE: `emit()` is never throws or rejects. */ +async function emit(denops: Denops, name: string): Promise { + try { + await denops.call("denops#_internal#event#emit", name); + } catch (e) { + console.error(`Failed to emit ${name}: ${e}`); + } +} + +function resolveScriptUrl(script: string): string { + try { + return toFileUrl(script).href; + } catch { + return new URL(script, import.meta.url).href; + } +} + +// See https://github.com/vim-denops/denops.vim/issues/358 for details +function isDenoCacheIssueError(e: unknown): boolean { + const expects = [ + "Could not find constraint in the list of versions: ", // Deno 1.40? + "Could not find version of ", // Deno 1.38 + ] as const; + if (e instanceof TypeError) { + return expects.some((expect) => e.message.startsWith(expect)); + } + return false; +} + +async function tryLoadImportMap( + scriptUrl: string, +): Promise { + const PATTERNS = [ + "deno.json", + "deno.jsonc", + "import_map.json", + "import_map.jsonc", + ]; + // Convert file URL to path for file operations + const scriptPath = fromFileUrl(new URL(scriptUrl)); + const parentDir = dirname(scriptPath); + for (const pattern of PATTERNS) { + const importMapPath = join(parentDir, pattern); + try { + return await loadImportMap(importMapPath); + } catch (err: unknown) { + if (err instanceof Deno.errors.NotFound) { + // Ignore NotFound errors and try the next pattern + continue; + } + throw err; // Rethrow other errors + } + } + return undefined; +} + +async function importPlugin(script: string): Promise { + const suffix = createScriptSuffix(script); + const importMap = await tryLoadImportMap(script); + if (importMap) { + const importer = new ImportMapImporter(importMap); + return await importer.import(`${script}${suffix}`); + } else { + return await import(`${script}${suffix}`); + } +} diff --git a/denops/@denops-private/plugin_test.ts b/denops/@denops-private/plugin_test.ts new file mode 100644 index 00000000..a1e91b22 --- /dev/null +++ b/denops/@denops-private/plugin_test.ts @@ -0,0 +1,571 @@ +import { + assert, + assertEquals, + assertInstanceOf, + assertRejects, + assertStringIncludes, +} from "jsr:@std/assert@^1.0.1"; +import { + assertSpyCall, + assertSpyCalls, + spy, + stub, +} from "jsr:@std/testing@^1.0.0/mock"; +import type { Denops, Meta } from "jsr:@denops/core@^7.0.0"; +import { flushPromises } from "jsr:@core/asyncutil@^1.1.1"; +import { unimplemented } from "jsr:@core/errorutil@^1.2.1"; +import { resolveTestDataURL } from "/denops-testdata/resolve.ts"; +import { Plugin } from "./plugin.ts"; + +const scriptValid = resolveTestDataURL("dummy_valid_plugin.ts"); +const scriptInvalid = resolveTestDataURL("dummy_invalid_plugin.ts"); +const scriptValidDispose = resolveTestDataURL("dummy_valid_dispose_plugin.ts"); +const scriptInvalidDispose = resolveTestDataURL( + "dummy_invalid_dispose_plugin.ts", +); +const scriptInvalidConstraint = resolveTestDataURL( + "dummy_invalid_constraint_plugin.ts", +); +const scriptInvalidConstraint2 = resolveTestDataURL( + "dummy_invalid_constraint_plugin2.ts", +); +const scriptWithImportMap = resolveTestDataURL( + "with_import_map/plugin_with_import_map.ts", +); + +Deno.test("Plugin", async (t) => { + const meta: Meta = { + mode: "debug", + host: "vim", + version: "dev", + platform: "linux", + }; + + const createDenops = (): Denops => ({ + meta, + dispatcher: {}, + name: "test", + context: {}, + call: () => unimplemented(), + batch: () => unimplemented(), + cmd: () => unimplemented(), + eval: () => unimplemented(), + dispatch: () => unimplemented(), + redraw: () => unimplemented(), + }); + + await t.step("new Plugin()", async (t) => { + await t.step("creates an instance", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptValid); + + assertInstanceOf(plugin, Plugin); + assertEquals(plugin.name, "test-plugin"); + assert(plugin.script.startsWith("file://")); + + // Wait for the plugin to load to prevent dangling promises + await plugin.waitLoaded(); + }); + }); + + await t.step(".waitLoaded()", async (t) => { + await t.step("resolves when plugin loads successfully", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptValid); + + await plugin.waitLoaded(); + + // Should emit DenopsSystemPluginPre and DenopsSystemPluginPost events + assertSpyCalls(_denops_call, 2); + assertSpyCall(_denops_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:test-plugin", + ], + }); + assertSpyCall(_denops_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPost:test-plugin", + ], + }); + + // Should call the plugin's main function + assertSpyCalls(_denops_cmd, 1); + assertSpyCall(_denops_cmd, 0, { + args: ["echo 'Hello, Denops!'"], + }); + }); + + await t.step("rejects when plugin entrypoint throws", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using console_error = spy(console, "error"); + + const plugin = new Plugin(denops, "test-plugin", scriptInvalid); + + await assertRejects( + () => plugin.waitLoaded(), + Error, + ); + + // Should emit DenopsSystemPluginPre and DenopsSystemPluginFail events + assertSpyCalls(_denops_call, 2); + assertSpyCall(_denops_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:test-plugin", + ], + }); + assertSpyCall(_denops_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginFail:test-plugin", + ], + }); + + // Should output error message + assert(console_error.calls.length >= 1); + assertStringIncludes( + console_error.calls[0].args[0] as string, + "Failed to load plugin 'test-plugin'", + ); + }); + + await t.step("shows warning for Deno cache issues", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using console_warn = spy(console, "warn"); + using console_error = spy(console, "error"); + + const plugin = new Plugin(denops, "test-plugin", scriptInvalidConstraint); + + await assertRejects( + () => plugin.waitLoaded(), + Error, + ); + + // Should show warning messages about Deno module cache issue + assert(console_warn.calls.length >= 4); + assertStringIncludes( + console_warn.calls[1].args[0] as string, + "Deno module cache issue is detected", + ); + + // Should show error message + assert(console_error.calls.length >= 1); + assertStringIncludes( + console_error.calls[0].args[0] as string, + "Failed to load plugin 'test-plugin'", + ); + }); + + await t.step("shows warning for version constraint issues", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using console_warn = spy(console, "warn"); + using _console_error = spy(console, "error"); + + const plugin = new Plugin( + denops, + "test-plugin", + scriptInvalidConstraint2, + ); + + await assertRejects( + () => plugin.waitLoaded(), + Error, + ); + + // Should show warning messages about Deno module cache issue + assert(console_warn.calls.length >= 4); + assertStringIncludes( + console_warn.calls[1].args[0] as string, + "Deno module cache issue is detected", + ); + }); + }); + + await t.step(".unload()", async (t) => { + await t.step("when plugin returns void", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptValid); + await plugin.waitLoaded(); + + // Reset spy calls + _denops_call.calls.length = 0; + + await plugin.unload(); + + // Should emit unload events + assertSpyCalls(_denops_call, 2); + assertSpyCall(_denops_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:test-plugin", + ], + }); + assertSpyCall(_denops_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:test-plugin", + ], + }); + }); + + await t.step("when plugin returns AsyncDisposable", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptValidDispose); + await plugin.waitLoaded(); + + // Reset spy calls + _denops_call.calls.length = 0; + _denops_cmd.calls.length = 0; + + await plugin.unload(); + + // Should call the dispose method + assertSpyCalls(_denops_cmd, 1); + assertSpyCall(_denops_cmd, 0, { + args: ["echo 'Goodbye, Denops!'"], + }); + + // Should emit unload events + assertSpyCalls(_denops_call, 2); + assertSpyCall(_denops_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:test-plugin", + ], + }); + assertSpyCall(_denops_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:test-plugin", + ], + }); + }); + + await t.step("when dispose method throws", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using console_error = spy(console, "error"); + + const plugin = new Plugin(denops, "test-plugin", scriptInvalidDispose); + await plugin.waitLoaded(); + + // Reset spy calls + _denops_call.calls.length = 0; + + await plugin.unload(); + + // Should output error message + assert(console_error.calls.length >= 1); + assertStringIncludes( + console_error.calls[0].args[0] as string, + "Failed to unload plugin 'test-plugin'", + ); + + // Should emit DenopsSystemPluginUnloadPre and DenopsSystemPluginUnloadFail events + assertSpyCalls(_denops_call, 2); + assertSpyCall(_denops_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:test-plugin", + ], + }); + assertSpyCall(_denops_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadFail:test-plugin", + ], + }); + }); + + await t.step("when plugin load failed", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + + const plugin = new Plugin(denops, "test-plugin", scriptInvalid); + + // Try to load (will fail) + await assertRejects(() => plugin.waitLoaded()); + + // Reset spy calls + _denops_call.calls.length = 0; + + // Unload should complete without emitting events + await plugin.unload(); + + // Should not emit any events since load failed + assertSpyCalls(_denops_call, 0); + }); + + await t.step("when called multiple times", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptValidDispose); + await plugin.waitLoaded(); + + // Reset spy calls + _denops_call.calls.length = 0; + _denops_cmd.calls.length = 0; + + // Call unload multiple times + const promise1 = plugin.unload(); + const promise2 = plugin.unload(); + const promise3 = plugin.unload(); + + await Promise.all([promise1, promise2, promise3]); + + // Should only dispose once + assertSpyCalls(_denops_cmd, 1); + assertSpyCall(_denops_cmd, 0, { + args: ["echo 'Goodbye, Denops!'"], + }); + + // Should only emit events once + assertSpyCalls(_denops_call, 2); + }); + }); + + await t.step(".call()", async (t) => { + await t.step("calls the plugin dispatcher function", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptValid); + await plugin.waitLoaded(); + + // Reset spy calls + _denops_cmd.calls.length = 0; + + // The plugin's dispatcher.test function should be available + const result = await plugin.call("test", "arg1", "arg2"); + + // Should call the dispatcher function + assertSpyCalls(_denops_cmd, 1); + assertSpyCall(_denops_cmd, 0, { + args: [`echo 'This is test call: ["arg1","arg2"]'`], + }); + + assertEquals(result, undefined); + }); + + await t.step("throws when dispatcher function does not exist", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptValid); + await plugin.waitLoaded(); + + await assertRejects( + () => plugin.call("nonexistent"), + Error, + "Failed to call 'nonexistent' API in 'test-plugin'", + ); + }); + + await t.step("includes stack trace when available", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptValid); + await plugin.waitLoaded(); + + // Override dispatcher to throw an error with stack + denops.dispatcher = { + failing: () => { + const error = new Error("Test error"); + error.stack = "Error: Test error\n at test.ts:123"; + throw error; + }, + }; + + await assertRejects( + () => plugin.call("failing"), + Error, + "Failed to call 'failing' API in 'test-plugin': Error: Test error\n at test.ts:123", + ); + }); + + await t.step("handles non-Error throws", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptValid); + await plugin.waitLoaded(); + + // Override dispatcher to throw a non-Error + denops.dispatcher = { + failing: () => { + throw "string error"; + }, + }; + + await assertRejects( + () => plugin.call("failing"), + Error, + "Failed to call 'failing' API in 'test-plugin': string error", + ); + }); + }); + + await t.step("script suffix handling", async (t) => { + await t.step("adds timestamp suffix on reload", async () => { + const denops1 = createDenops(); + const denops2 = createDenops(); + using _denops_call1 = stub(denops1, "call"); + using _denops_call2 = stub(denops2, "call"); + using _denops_cmd1 = stub(denops1, "cmd"); + using _denops_cmd2 = stub(denops2, "cmd"); + + // Load the same script twice with different plugin instances + const plugin1 = new Plugin(denops1, "test-plugin-1", scriptValid); + await plugin1.waitLoaded(); + + await flushPromises(); + + const plugin2 = new Plugin(denops2, "test-plugin-2", scriptValid); + await plugin2.waitLoaded(); + + // When loading the same script multiple times, the Module cache + // mechanism handles the deduplication. The test should verify + // that both plugins loaded successfully. + assertEquals(plugin1.name, "test-plugin-1"); + assertEquals(plugin2.name, "test-plugin-2"); + + // Both should have loaded the same script + const base1 = plugin1.script.split("#")[0]; + const base2 = plugin2.script.split("#")[0]; + assertEquals(base1, base2); + }); + }); + + await t.step("event emission error handling", async (t) => { + await t.step("continues even if event emission fails", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call", () => { + throw new Error("Event emission failed"); + }); + using _denops_cmd = stub(denops, "cmd"); + using console_error = spy(console, "error"); + + const plugin = new Plugin(denops, "test-plugin", scriptValid); + + // Should not throw even if event emission fails + // The plugin still loads successfully despite emit errors + await plugin.waitLoaded(); + + // Should log errors for failed event emissions + assert(console_error.calls.length >= 2); + assertStringIncludes( + console_error.calls[0].args[0] as string, + "Failed to emit DenopsSystemPluginPre:test-plugin", + ); + assertStringIncludes( + console_error.calls[1].args[0] as string, + "Failed to emit DenopsSystemPluginPost:test-plugin", + ); + + // Plugin should still be loaded + assertSpyCalls(_denops_cmd, 1); + assertSpyCall(_denops_cmd, 0, { + args: ["echo 'Hello, Denops!'"], + }); + }); + }); + + await t.step("import map support", async (t) => { + await t.step("loads plugin with import_map.json", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptWithImportMap); + + await plugin.waitLoaded(); + + // Should emit events + assertSpyCalls(_denops_call, 2); + assertSpyCall(_denops_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:test-plugin", + ], + }); + assertSpyCall(_denops_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPost:test-plugin", + ], + }); + + // Should call the plugin's main function + assertSpyCalls(_denops_cmd, 1); + assertSpyCall(_denops_cmd, 0, { + args: ["echo 'Import map plugin initialized'"], + }); + }); + + await t.step("plugin can use mapped imports", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptWithImportMap); + await plugin.waitLoaded(); + + // Reset spy calls + _denops_cmd.calls.length = 0; + + // Call the dispatcher function + const result = await plugin.call("test"); + + // Should execute the command with the message from the mapped import + assertSpyCalls(_denops_cmd, 1); + assertSpyCall(_denops_cmd, 0, { + args: ["echo 'Import map works for test-plugin!'"], + }); + + // Should return the greeting from the mapped import + assertEquals(result, "Hello from mapped import!"); + }); + + await t.step("works without import map", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + // Use a regular plugin without import map + const plugin = new Plugin(denops, "test-plugin", scriptValid); + + await plugin.waitLoaded(); + + // Should load normally + assertSpyCalls(_denops_call, 2); + assertSpyCalls(_denops_cmd, 1); + assertSpyCall(_denops_cmd, 0, { + args: ["echo 'Hello, Denops!'"], + }); + }); + }); +}); diff --git a/denops/@denops-private/service.ts b/denops/@denops-private/service.ts index ba854e72..65918810 100644 --- a/denops/@denops-private/service.ts +++ b/denops/@denops-private/service.ts @@ -1,8 +1,8 @@ -import type { Denops, Entrypoint, Meta } from "jsr:@denops/core@^7.0.0"; -import { toFileUrl } from "jsr:@std/path@^1.0.2/to-file-url"; +import type { Meta } from "jsr:@denops/core@^7.0.0"; import { toErrorObject } from "jsr:@core/errorutil@^1.2.1"; import { DenopsImpl, type Host } from "./denops.ts"; import type { CallbackId, Service as HostService } from "./host.ts"; +import { Plugin } from "./plugin.ts"; /** * Service manage plugins and is visible from the host (Vim/Neovim) through `invoke()` function. @@ -192,142 +192,6 @@ function assertValidPluginName(name: string) { } } -type PluginModule = { - main: Entrypoint; -}; - -class Plugin { - #denops: Denops; - #loadedWaiter: Promise; - #unloadedWaiter?: Promise; - #disposable: AsyncDisposable = voidAsyncDisposable; - - readonly name: string; - readonly script: string; - - constructor(denops: Denops, name: string, script: string) { - this.#denops = denops; - this.name = name; - this.script = resolveScriptUrl(script); - this.#loadedWaiter = this.#load(); - } - - waitLoaded(): Promise { - return this.#loadedWaiter; - } - - async #load(): Promise { - const suffix = createScriptSuffix(this.script); - await emit(this.#denops, `DenopsSystemPluginPre:${this.name}`); - try { - const mod: PluginModule = await import(`${this.script}${suffix}`); - this.#disposable = await mod.main(this.#denops) ?? voidAsyncDisposable; - } catch (e) { - // Show a warning message when Deno module cache issue is detected - // https://github.com/vim-denops/denops.vim/issues/358 - if (isDenoCacheIssueError(e)) { - console.warn("*".repeat(80)); - console.warn(`Deno module cache issue is detected.`); - console.warn( - `Execute 'call denops#cache#update(#{reload: v:true})' and restart Vim/Neovim.`, - ); - console.warn( - `See https://github.com/vim-denops/denops.vim/issues/358 for more detail.`, - ); - console.warn("*".repeat(80)); - } - console.error(`Failed to load plugin '${this.name}': ${e}`); - await emit(this.#denops, `DenopsSystemPluginFail:${this.name}`); - throw e; - } - await emit(this.#denops, `DenopsSystemPluginPost:${this.name}`); - } - - unload(): Promise { - if (!this.#unloadedWaiter) { - this.#unloadedWaiter = this.#unload(); - } - return this.#unloadedWaiter; - } - - async #unload(): Promise { - try { - // Wait for the load to complete to make the events atomically. - await this.#loadedWaiter; - } catch { - // Load failed, do nothing - return; - } - const disposable = this.#disposable; - this.#disposable = voidAsyncDisposable; - await emit(this.#denops, `DenopsSystemPluginUnloadPre:${this.name}`); - try { - await disposable[Symbol.asyncDispose](); - } catch (e) { - console.error(`Failed to unload plugin '${this.name}': ${e}`); - await emit(this.#denops, `DenopsSystemPluginUnloadFail:${this.name}`); - return; - } - await emit(this.#denops, `DenopsSystemPluginUnloadPost:${this.name}`); - } - - async call(fn: string, ...args: unknown[]): Promise { - try { - return await this.#denops.dispatcher[fn](...args); - } catch (err) { - const errMsg = err instanceof Error - ? err.stack ?? err.message // Prefer 'stack' if available - : String(err); - throw new Error( - `Failed to call '${fn}' API in '${this.name}': ${errMsg}`, - ); - } - } -} - -const voidAsyncDisposable = { - [Symbol.asyncDispose]: () => Promise.resolve(), -} as const satisfies AsyncDisposable; - -const loadedScripts = new Set(); - -function createScriptSuffix(script: string): string { - // Import module with fragment so that reload works properly - // https://github.com/vim-denops/denops.vim/issues/227 - const suffix = loadedScripts.has(script) ? `#${performance.now()}` : ""; - loadedScripts.add(script); - return suffix; -} - -/** NOTE: `emit()` is never throws or rejects. */ -async function emit(denops: Denops, name: string): Promise { - try { - await denops.call("denops#_internal#event#emit", name); - } catch (e) { - console.error(`Failed to emit ${name}: ${e}`); - } -} - -function resolveScriptUrl(script: string): string { - try { - return toFileUrl(script).href; - } catch { - return new URL(script, import.meta.url).href; - } -} - -// See https://github.com/vim-denops/denops.vim/issues/358 for details -function isDenoCacheIssueError(e: unknown): boolean { - const expects = [ - "Could not find constraint in the list of versions: ", // Deno 1.40? - "Could not find version of ", // Deno 1.38 - ] as const; - if (e instanceof TypeError) { - return expects.some((expect) => e.message.startsWith(expect)); - } - return false; -} - // NOTE: // Vim/Neovim does not handle JavaScript Error instance thus use string instead function toVimError(err: unknown): string { diff --git a/tests/denops/testdata/with_import_map/helper.ts b/tests/denops/testdata/with_import_map/helper.ts new file mode 100644 index 00000000..8ccde035 --- /dev/null +++ b/tests/denops/testdata/with_import_map/helper.ts @@ -0,0 +1,5 @@ +export const greeting = "Hello from mapped import!"; + +export function getMessage(name: string): string { + return `Import map works for ${name}!`; +} diff --git a/tests/denops/testdata/with_import_map/import_map.json b/tests/denops/testdata/with_import_map/import_map.json new file mode 100644 index 00000000..57491054 --- /dev/null +++ b/tests/denops/testdata/with_import_map/import_map.json @@ -0,0 +1,7 @@ +{ + "imports": { + "@test/": "../", + "@test/lib": "../dummy_valid_plugin.ts", + "@test/helper": "./helper.ts" + } +} diff --git a/tests/denops/testdata/with_import_map/import_map.jsonc b/tests/denops/testdata/with_import_map/import_map.jsonc new file mode 100644 index 00000000..091108c1 --- /dev/null +++ b/tests/denops/testdata/with_import_map/import_map.jsonc @@ -0,0 +1,10 @@ +{ + // This is a JSONC import map with comments + "imports": { + "@test/": "../", + // Map to a specific module + "@test/lib": "../dummy_valid_plugin.ts", + // Map to the helper module + "@test/helper": "./helper.ts" + } +} diff --git a/tests/denops/testdata/with_import_map/plugin_with_import_map.ts b/tests/denops/testdata/with_import_map/plugin_with_import_map.ts new file mode 100644 index 00000000..81229c88 --- /dev/null +++ b/tests/denops/testdata/with_import_map/plugin_with_import_map.ts @@ -0,0 +1,13 @@ +import type { Entrypoint } from "jsr:@denops/core@^7.0.0"; +import { getMessage, greeting } from "@test/helper"; + +export const main: Entrypoint = async (denops) => { + denops.dispatcher = { + test: async () => { + const message = getMessage("test-plugin"); + await denops.cmd(`echo '${message}'`); + return greeting; + }, + }; + await denops.cmd("echo 'Import map plugin initialized'"); +};