diff --git a/deno.jsonc b/deno.jsonc index 7ff94edd..416597c5 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -15,6 +15,8 @@ "exclude": [ ".coverage/", "tests/denops/testdata/no_check/", + "tests/denops/testdata/with_deno_json/", + "tests/denops/testdata/with_deno_json2/", "tests/denops/testdata/with_import_map/" ], "imports": { diff --git a/denops/@denops-private/plugin.ts b/denops/@denops-private/plugin.ts index 7a828fd1..b539fdb8 100644 --- a/denops/@denops-private/plugin.ts +++ b/denops/@denops-private/plugin.ts @@ -1,3 +1,6 @@ +import { isObjectOf } from "@core/unknownutil/is/object-of"; +import { isString } from "@core/unknownutil/is/string"; +import { isUndefined } from "@core/unknownutil/is/undefined"; import type { Denops, Entrypoint } from "@denops/core"; import { type ImportMap, @@ -8,8 +11,6 @@ import { import { ensure } from "@core/unknownutil"; import { toFileUrl } from "@std/path/to-file-url"; import { fromFileUrl } from "@std/path/from-file-url"; -import { join } from "@std/path/join"; -import { dirname } from "@std/path/dirname"; import { parse as parseJsonc } from "@std/jsonc"; type PluginModule = { @@ -21,14 +22,18 @@ export class Plugin { #loadedWaiter: Promise; #unloadedWaiter?: Promise; #disposable: AsyncDisposable = voidAsyncDisposable; + #scriptUrl: URL; readonly name: string; - readonly script: string; + + get script(): string { + return this.#scriptUrl.href; + } constructor(denops: Denops, name: string, script: string) { this.#denops = denops; this.name = name; - this.script = resolveScriptUrl(script); + this.#scriptUrl = resolveScriptUrl(script); this.#loadedWaiter = this.#load(); } @@ -39,7 +44,7 @@ export class Plugin { async #load(): Promise { await emit(this.#denops, `DenopsSystemPluginPre:${this.name}`); try { - const mod: PluginModule = await importPlugin(this.script); + const mod: PluginModule = await importPlugin(this.#scriptUrl); this.#disposable = await mod.main(this.#denops) ?? voidAsyncDisposable; } catch (e) { // Show a warning message when Deno module cache issue is detected @@ -110,12 +115,16 @@ const voidAsyncDisposable = { const loadedScripts = new Set(); -function createScriptSuffix(script: string): string { +function refreshScriptFragment(scriptUrl: URL): URL { // 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; + if (loadedScripts.has(scriptUrl.href)) { + // Keep the original fragment and add a timestamp + const fragment = `${scriptUrl.hash}#${performance.now()}`; + return new URL(fragment, scriptUrl); + } + loadedScripts.add(scriptUrl.href); + return scriptUrl; } /** NOTE: `emit()` is never throws or rejects. */ @@ -127,11 +136,11 @@ async function emit(denops: Denops, name: string): Promise { } } -function resolveScriptUrl(script: string): string { +function resolveScriptUrl(script: string): URL { try { - return toFileUrl(script).href; + return toFileUrl(script); } catch { - return new URL(script, import.meta.url).href; + return new URL(script); } } @@ -147,10 +156,23 @@ function isDenoCacheIssueError(e: unknown): boolean { return false; } +async function loadJson(fileUrl: URL): Promise { + const content = await Deno.readTextFile(fileUrl); + // Always parse as JSONC to be more permissive + return parseJsonc(content); +} + +const hasImportMapProperty = isObjectOf({ + importMap: isString, + // If `imports` or `scopes` exists, they will be override `importMap` + imports: isUndefined, + scopes: isUndefined, +}); + async function tryLoadImportMap( - script: string, + scriptUrl: URL, ): Promise { - if (script.startsWith("http://") || script.startsWith("https://")) { + if (scriptUrl.protocol !== "file:") { // We cannot load import maps for remote scripts return undefined; } @@ -160,20 +182,13 @@ async function tryLoadImportMap( "import_map.json", "import_map.jsonc", ]; - // Convert file URL to path for file operations - const scriptPath = script.startsWith("file://") - ? fromFileUrl(new URL(script)) - : script; - const parentDir = dirname(scriptPath); for (const pattern of PATTERNS) { - const importMapPath = join(parentDir, pattern); + let importMapUrl = new URL(pattern, scriptUrl); + + // Try to load the import map or deno configuration file + let jsonValue: unknown; try { - return await loadImportMap(importMapPath, { - loader: (path: string) => { - const content = Deno.readTextFileSync(path); - return ensure(parseJsonc(content), isImportMap); - }, - }); + jsonValue = await loadJson(importMapUrl); } catch (err: unknown) { if (err instanceof Deno.errors.NotFound) { // Ignore NotFound errors and try the next pattern @@ -181,17 +196,32 @@ async function tryLoadImportMap( } throw err; // Rethrow other errors } + + // Resolve import map path in the deno configuration and load it + if ( + /\/deno\.jsonc?$/.test(importMapUrl.pathname) && + hasImportMapProperty(jsonValue) + ) { + importMapUrl = new URL(jsonValue.importMap, importMapUrl); + jsonValue = await loadJson(importMapUrl); + } + + // Resolve relative paths in the import map and return it + const importMapPath = fromFileUrl(importMapUrl); + return await loadImportMap(importMapPath, { + loader: () => ensure(jsonValue, isImportMap), + }); } return undefined; } -async function importPlugin(script: string): Promise { - const suffix = createScriptSuffix(script); - const importMap = await tryLoadImportMap(script); +async function importPlugin(scriptUrl: URL): Promise { + scriptUrl = refreshScriptFragment(scriptUrl); + const importMap = await tryLoadImportMap(scriptUrl); if (importMap) { const importer = new ImportMapImporter(importMap); - return await importer.import(`${script}${suffix}`); + return await importer.import(scriptUrl.href); } else { - return await import(`${script}${suffix}`); + return await import(scriptUrl.href); } } diff --git a/denops/@denops-private/plugin_test.ts b/denops/@denops-private/plugin_test.ts index 3f9dbcb4..ae98cd96 100644 --- a/denops/@denops-private/plugin_test.ts +++ b/denops/@denops-private/plugin_test.ts @@ -27,6 +27,12 @@ const scriptInvalidConstraint2 = resolveTestDataURL( const scriptWithImportMap = resolveTestDataURL( "with_import_map/plugin_with_import_map.ts", ); +const scriptWithDenoJson = resolveTestDataURL( + "with_deno_json/plugin_with_deno_json.ts", +); +const scriptWithDenoJson2 = resolveTestDataURL( + "with_deno_json2/plugin_with_deno_json.ts", +); Deno.test("Plugin", async (t) => { const meta: Meta = { @@ -563,4 +569,85 @@ Deno.test("Plugin", async (t) => { }); }); }); + + await t.step("importMap property support", async (t) => { + await t.step("loads plugin with deno.json", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptWithDenoJson); + + 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 'Deno json 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", scriptWithDenoJson); + 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 'Relative import map works for test-plugin!'"], + }); + + // Should return the greeting from the mapped import + assertEquals(result, "Hello from relative import map!"); + }); + + await t.step("importMap is overridden by imports", async () => { + const denops = createDenops(); + using _denops_call = stub(denops, "call"); + using _denops_cmd = stub(denops, "cmd"); + + const plugin = new Plugin(denops, "test-plugin", scriptWithDenoJson2); + 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!"); + }); + }); }); diff --git a/tests/denops/testdata/with_deno_json/deno.json b/tests/denops/testdata/with_deno_json/deno.json new file mode 100644 index 00000000..8445f566 --- /dev/null +++ b/tests/denops/testdata/with_deno_json/deno.json @@ -0,0 +1,3 @@ +{ + "importMap": "./other_path/other_name.json" +} diff --git a/tests/denops/testdata/with_deno_json/other_path/helper.ts b/tests/denops/testdata/with_deno_json/other_path/helper.ts new file mode 100644 index 00000000..5219291a --- /dev/null +++ b/tests/denops/testdata/with_deno_json/other_path/helper.ts @@ -0,0 +1,5 @@ +export const greeting = "Hello from relative import map!"; + +export function getMessage(name: string): string { + return `Relative import map works for ${name}!`; +} diff --git a/tests/denops/testdata/with_deno_json/other_path/other_name.json b/tests/denops/testdata/with_deno_json/other_path/other_name.json new file mode 100644 index 00000000..b51434fa --- /dev/null +++ b/tests/denops/testdata/with_deno_json/other_path/other_name.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@test/helper": "./helper.ts" + } +} diff --git a/tests/denops/testdata/with_deno_json/plugin_with_deno_json.ts b/tests/denops/testdata/with_deno_json/plugin_with_deno_json.ts new file mode 100644 index 00000000..a239cad2 --- /dev/null +++ b/tests/denops/testdata/with_deno_json/plugin_with_deno_json.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 'Deno json plugin initialized'"); +}; diff --git a/tests/denops/testdata/with_deno_json2/deno.jsonc b/tests/denops/testdata/with_deno_json2/deno.jsonc new file mode 100644 index 00000000..b06e574c --- /dev/null +++ b/tests/denops/testdata/with_deno_json2/deno.jsonc @@ -0,0 +1,8 @@ +{ + // `importMap` is defined, but ... + "importMap": "./other_path/other_name.json", + // `imports` is defined, so it will override the `importMap` + "imports": { + "@test/helper": "./helper.ts" + } +} diff --git a/tests/denops/testdata/with_deno_json2/helper.ts b/tests/denops/testdata/with_deno_json2/helper.ts new file mode 100644 index 00000000..8ccde035 --- /dev/null +++ b/tests/denops/testdata/with_deno_json2/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_deno_json2/other_path/helper.ts b/tests/denops/testdata/with_deno_json2/other_path/helper.ts new file mode 100644 index 00000000..5219291a --- /dev/null +++ b/tests/denops/testdata/with_deno_json2/other_path/helper.ts @@ -0,0 +1,5 @@ +export const greeting = "Hello from relative import map!"; + +export function getMessage(name: string): string { + return `Relative import map works for ${name}!`; +} diff --git a/tests/denops/testdata/with_deno_json2/other_path/other_name.json b/tests/denops/testdata/with_deno_json2/other_path/other_name.json new file mode 100644 index 00000000..b51434fa --- /dev/null +++ b/tests/denops/testdata/with_deno_json2/other_path/other_name.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@test/helper": "./helper.ts" + } +} diff --git a/tests/denops/testdata/with_deno_json2/plugin_with_deno_json.ts b/tests/denops/testdata/with_deno_json2/plugin_with_deno_json.ts new file mode 100644 index 00000000..a239cad2 --- /dev/null +++ b/tests/denops/testdata/with_deno_json2/plugin_with_deno_json.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 'Deno json plugin initialized'"); +};