Skip to content

Commit 8ee1fcc

Browse files
authored
Merge pull request #446 from vim-denops/support-import-map
2 parents 2c1717f + 955a3fe commit 8ee1fcc

File tree

8 files changed

+793
-139
lines changed

8 files changed

+793
-139
lines changed

deno.jsonc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
},
1414
"exclude": [
1515
".coverage/",
16-
"tests/denops/testdata/no_check/"
16+
"tests/denops/testdata/no_check/",
17+
"tests/denops/testdata/with_import_map/"
1718
],
1819
// NOTE: Import maps should only be used from test modules.
1920
"imports": {

denops/@denops-private/plugin.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import type { Denops, Entrypoint } from "jsr:@denops/core@^7.0.0";
2+
import {
3+
type ImportMap,
4+
ImportMapImporter,
5+
loadImportMap,
6+
} from "jsr:@lambdalisue/import-map-importer@^0.3.1";
7+
import { toFileUrl } from "jsr:@std/path@^1.0.2/to-file-url";
8+
import { fromFileUrl } from "jsr:@std/path@^1.0.2/from-file-url";
9+
import { join } from "jsr:@std/path@^1.0.2/join";
10+
import { dirname } from "jsr:@std/path@^1.0.2/dirname";
11+
12+
type PluginModule = {
13+
main: Entrypoint;
14+
};
15+
16+
export class Plugin {
17+
#denops: Denops;
18+
#loadedWaiter: Promise<void>;
19+
#unloadedWaiter?: Promise<void>;
20+
#disposable: AsyncDisposable = voidAsyncDisposable;
21+
22+
readonly name: string;
23+
readonly script: string;
24+
25+
constructor(denops: Denops, name: string, script: string) {
26+
this.#denops = denops;
27+
this.name = name;
28+
this.script = resolveScriptUrl(script);
29+
this.#loadedWaiter = this.#load();
30+
}
31+
32+
waitLoaded(): Promise<void> {
33+
return this.#loadedWaiter;
34+
}
35+
36+
async #load(): Promise<void> {
37+
await emit(this.#denops, `DenopsSystemPluginPre:${this.name}`);
38+
try {
39+
const mod: PluginModule = await importPlugin(this.script);
40+
this.#disposable = await mod.main(this.#denops) ?? voidAsyncDisposable;
41+
} catch (e) {
42+
// Show a warning message when Deno module cache issue is detected
43+
// https://github.com/vim-denops/denops.vim/issues/358
44+
if (isDenoCacheIssueError(e)) {
45+
console.warn("*".repeat(80));
46+
console.warn(`Deno module cache issue is detected.`);
47+
console.warn(
48+
`Execute 'call denops#cache#update(#{reload: v:true})' and restart Vim/Neovim.`,
49+
);
50+
console.warn(
51+
`See https://github.com/vim-denops/denops.vim/issues/358 for more detail.`,
52+
);
53+
console.warn("*".repeat(80));
54+
}
55+
console.error(`Failed to load plugin '${this.name}': ${e}`);
56+
await emit(this.#denops, `DenopsSystemPluginFail:${this.name}`);
57+
throw e;
58+
}
59+
await emit(this.#denops, `DenopsSystemPluginPost:${this.name}`);
60+
}
61+
62+
unload(): Promise<void> {
63+
if (!this.#unloadedWaiter) {
64+
this.#unloadedWaiter = this.#unload();
65+
}
66+
return this.#unloadedWaiter;
67+
}
68+
69+
async #unload(): Promise<void> {
70+
try {
71+
// Wait for the load to complete to make the events atomically.
72+
await this.#loadedWaiter;
73+
} catch {
74+
// Load failed, do nothing
75+
return;
76+
}
77+
const disposable = this.#disposable;
78+
this.#disposable = voidAsyncDisposable;
79+
await emit(this.#denops, `DenopsSystemPluginUnloadPre:${this.name}`);
80+
try {
81+
await disposable[Symbol.asyncDispose]();
82+
} catch (e) {
83+
console.error(`Failed to unload plugin '${this.name}': ${e}`);
84+
await emit(this.#denops, `DenopsSystemPluginUnloadFail:${this.name}`);
85+
return;
86+
}
87+
await emit(this.#denops, `DenopsSystemPluginUnloadPost:${this.name}`);
88+
}
89+
90+
async call(fn: string, ...args: unknown[]): Promise<unknown> {
91+
try {
92+
return await this.#denops.dispatcher[fn](...args);
93+
} catch (err) {
94+
const errMsg = err instanceof Error
95+
? err.stack ?? err.message // Prefer 'stack' if available
96+
: String(err);
97+
throw new Error(
98+
`Failed to call '${fn}' API in '${this.name}': ${errMsg}`,
99+
);
100+
}
101+
}
102+
}
103+
104+
const voidAsyncDisposable = {
105+
[Symbol.asyncDispose]: () => Promise.resolve(),
106+
} as const satisfies AsyncDisposable;
107+
108+
const loadedScripts = new Set<string>();
109+
110+
function createScriptSuffix(script: string): string {
111+
// Import module with fragment so that reload works properly
112+
// https://github.com/vim-denops/denops.vim/issues/227
113+
const suffix = loadedScripts.has(script) ? `#${performance.now()}` : "";
114+
loadedScripts.add(script);
115+
return suffix;
116+
}
117+
118+
/** NOTE: `emit()` is never throws or rejects. */
119+
async function emit(denops: Denops, name: string): Promise<void> {
120+
try {
121+
await denops.call("denops#_internal#event#emit", name);
122+
} catch (e) {
123+
console.error(`Failed to emit ${name}: ${e}`);
124+
}
125+
}
126+
127+
function resolveScriptUrl(script: string): string {
128+
try {
129+
return toFileUrl(script).href;
130+
} catch {
131+
return new URL(script, import.meta.url).href;
132+
}
133+
}
134+
135+
// See https://github.com/vim-denops/denops.vim/issues/358 for details
136+
function isDenoCacheIssueError(e: unknown): boolean {
137+
const expects = [
138+
"Could not find constraint in the list of versions: ", // Deno 1.40?
139+
"Could not find version of ", // Deno 1.38
140+
] as const;
141+
if (e instanceof TypeError) {
142+
return expects.some((expect) => e.message.startsWith(expect));
143+
}
144+
return false;
145+
}
146+
147+
async function tryLoadImportMap(
148+
scriptUrl: string,
149+
): Promise<ImportMap | undefined> {
150+
const PATTERNS = [
151+
"deno.json",
152+
"deno.jsonc",
153+
"import_map.json",
154+
"import_map.jsonc",
155+
];
156+
// Convert file URL to path for file operations
157+
const scriptPath = fromFileUrl(new URL(scriptUrl));
158+
const parentDir = dirname(scriptPath);
159+
for (const pattern of PATTERNS) {
160+
const importMapPath = join(parentDir, pattern);
161+
try {
162+
return await loadImportMap(importMapPath);
163+
} catch (err: unknown) {
164+
if (err instanceof Deno.errors.NotFound) {
165+
// Ignore NotFound errors and try the next pattern
166+
continue;
167+
}
168+
throw err; // Rethrow other errors
169+
}
170+
}
171+
return undefined;
172+
}
173+
174+
async function importPlugin(script: string): Promise<PluginModule> {
175+
const suffix = createScriptSuffix(script);
176+
const importMap = await tryLoadImportMap(script);
177+
if (importMap) {
178+
const importer = new ImportMapImporter(importMap);
179+
return await importer.import<PluginModule>(`${script}${suffix}`);
180+
} else {
181+
return await import(`${script}${suffix}`);
182+
}
183+
}

0 commit comments

Comments
 (0)