Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
92 changes: 61 additions & 31 deletions denops/@denops-private/plugin.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 = {
Expand All @@ -21,14 +22,18 @@ export class Plugin {
#loadedWaiter: Promise<void>;
#unloadedWaiter?: Promise<void>;
#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();
}

Expand All @@ -39,7 +44,7 @@ export class Plugin {
async #load(): Promise<void> {
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
Expand Down Expand Up @@ -110,12 +115,16 @@ const voidAsyncDisposable = {

const loadedScripts = new Set<string>();

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. */
Expand All @@ -127,11 +136,11 @@ async function emit(denops: Denops, name: string): Promise<void> {
}
}

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);
}
}

Expand All @@ -147,10 +156,23 @@ function isDenoCacheIssueError(e: unknown): boolean {
return false;
}

async function loadJson(fileUrl: URL): Promise<unknown> {
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<ImportMap | undefined> {
if (script.startsWith("http://") || script.startsWith("https://")) {
if (scriptUrl.protocol !== "file:") {
// We cannot load import maps for remote scripts
return undefined;
}
Expand All @@ -160,38 +182,46 @@ 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
continue;
}
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<PluginModule> {
const suffix = createScriptSuffix(script);
const importMap = await tryLoadImportMap(script);
async function importPlugin(scriptUrl: URL): Promise<PluginModule> {
scriptUrl = refreshScriptFragment(scriptUrl);
const importMap = await tryLoadImportMap(scriptUrl);
if (importMap) {
const importer = new ImportMapImporter(importMap);
return await importer.import<PluginModule>(`${script}${suffix}`);
return await importer.import<PluginModule>(scriptUrl.href);
} else {
return await import(`${script}${suffix}`);
return await import(scriptUrl.href);
}
}
87 changes: 87 additions & 0 deletions denops/@denops-private/plugin_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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!");
});
});
});
3 changes: 3 additions & 0 deletions tests/denops/testdata/with_deno_json/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"importMap": "./other_path/other_name.json"
}
5 changes: 5 additions & 0 deletions tests/denops/testdata/with_deno_json/other_path/helper.ts
Original file line number Diff line number Diff line change
@@ -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}!`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"imports": {
"@test/helper": "./helper.ts"
}
}
13 changes: 13 additions & 0 deletions tests/denops/testdata/with_deno_json/plugin_with_deno_json.ts
Original file line number Diff line number Diff line change
@@ -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'");
};
8 changes: 8 additions & 0 deletions tests/denops/testdata/with_deno_json2/deno.jsonc
Original file line number Diff line number Diff line change
@@ -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"
}
}
5 changes: 5 additions & 0 deletions tests/denops/testdata/with_deno_json2/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const greeting = "Hello from mapped import!";

export function getMessage(name: string): string {
return `Import map works for ${name}!`;
}
5 changes: 5 additions & 0 deletions tests/denops/testdata/with_deno_json2/other_path/helper.ts
Original file line number Diff line number Diff line change
@@ -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}!`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"imports": {
"@test/helper": "./helper.ts"
}
}
13 changes: 13 additions & 0 deletions tests/denops/testdata/with_deno_json2/plugin_with_deno_json.ts
Original file line number Diff line number Diff line change
@@ -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'");
};
Loading