Skip to content

Commit

Permalink
Merge pull request #418 from vim-denops/plugin-name
Browse files Browse the repository at this point in the history
👍 rejects if the plugin name is invalid
  • Loading branch information
lambdalisue authored Sep 16, 2024
2 parents aed70a1 + 908c59e commit d0718c2
Show file tree
Hide file tree
Showing 14 changed files with 468 additions and 69 deletions.
1 change: 1 addition & 0 deletions autoload/denops/_internal/plugin.vim
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const s:STATE_LOADED = 'loaded'
const s:STATE_UNLOADING = 'unloading'
const s:STATE_FAILED = 'failed'

" NOTE: same as denops/@denops-private/service.ts
const s:VALID_NAME_PATTERN = '^[-_0-9a-zA-Z]\+$'

let s:plugins = {}
Expand Down
21 changes: 18 additions & 3 deletions denops/@denops-private/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export class Service implements HostService, AsyncDisposable {
if (!this.#host) {
throw new Error("No host is bound to the service");
}
assertValidPluginName(name);
if (this.#plugins.has(name)) {
if (this.#meta.mode === "debug") {
console.log(`A denops plugin '${name}' is already loaded. Skip`);
Expand Down Expand Up @@ -79,21 +80,24 @@ export class Service implements HostService, AsyncDisposable {
}

async unload(name: string): Promise<void> {
assertValidPluginName(name);
await this.#unload(name);
}

async reload(name: string): Promise<void> {
assertValidPluginName(name);
const plugin = await this.#unload(name);
if (plugin) {
await this.load(name, plugin.script);
}
}

waitLoaded(name: string): Promise<void> {
async waitLoaded(name: string): Promise<void> {
if (this.#closed) {
return Promise.reject(new Error("Service closed"));
throw new Error("Service closed");
}
return this.#getWaiter(name).promise;
assertValidPluginName(name);
await this.#getWaiter(name).promise;
}

interrupt(reason?: unknown): void {
Expand All @@ -111,6 +115,7 @@ export class Service implements HostService, AsyncDisposable {

async dispatch(name: string, fn: string, args: unknown[]): Promise<unknown> {
try {
assertValidPluginName(name);
return await this.#dispatch(name, fn, args);
} catch (e) {
throw toVimError(e);
Expand All @@ -128,6 +133,7 @@ export class Service implements HostService, AsyncDisposable {
throw new Error("No host is bound to the service");
}
try {
assertValidPluginName(name);
const r = await this.#dispatch(name, fn, args);
try {
await this.#host.call("denops#callback#call", success, r);
Expand Down Expand Up @@ -173,6 +179,15 @@ export class Service implements HostService, AsyncDisposable {
}
}

// NOTE: same as autoload/denops/_internal/plugin.vim
const VALID_NAME_PATTERN = /^[-_0-9a-zA-Z]+$/;

function assertValidPluginName(name: string) {
if (!VALID_NAME_PATTERN.test(name)) {
throw new TypeError(`Invalid plugin name: ${name}`);
}
}

type PluginModule = {
main: Entrypoint;
};
Expand Down
128 changes: 128 additions & 0 deletions denops/@denops-private/service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
assertInstanceOf,
assertMatch,
assertNotStrictEquals,
assertObjectMatch,
assertRejects,
assertStrictEquals,
assertStringIncludes,
assertThrows,
} from "jsr:@std/assert@^1.0.1";
import {
Expand All @@ -21,6 +23,7 @@ import { toFileUrl } from "jsr:@std/path@^1.0.2/to-file-url";
import type { Meta } from "jsr:@denops/core@^7.0.0";
import { promiseState } from "jsr:@lambdalisue/async@^2.1.1";
import { unimplemented } from "jsr:@lambdalisue/errorutil@^1.1.0";
import { INVALID_PLUGIN_NAMES } from "/denops-testdata/invalid_plugin_names.ts";
import { resolveTestDataURL } from "/denops-testdata/resolve.ts";
import type { Host } from "./denops.ts";
import { Service } from "./service.ts";
Expand Down Expand Up @@ -439,6 +442,26 @@ Deno.test("Service", async (t) => {
]);
});
});

for (const [plugin_name, label] of INVALID_PLUGIN_NAMES) {
await t.step(`if the plugin name is invalid (${label})`, async (t) => {
const service = new Service(meta);
service.bind(host);
using host_call = stub(host, "call");

await t.step("rejects", async () => {
await assertRejects(
() => service.load(plugin_name, scriptValid),
TypeError,
`Invalid plugin name: ${plugin_name}`,
);
});

await t.step("does not calls the host", () => {
assertSpyCalls(host_call, 0);
});
});
}
});

await t.step(".unload()", async (t) => {
Expand Down Expand Up @@ -760,6 +783,26 @@ Deno.test("Service", async (t) => {
]);
});
});

for (const [plugin_name, label] of INVALID_PLUGIN_NAMES) {
await t.step(`if the plugin name is invalid (${label})`, async (t) => {
const service = new Service(meta);
service.bind(host);
using host_call = stub(host, "call");

await t.step("rejects", async () => {
await assertRejects(
() => service.unload(plugin_name),
TypeError,
`Invalid plugin name: ${plugin_name}`,
);
});

await t.step("does not calls the host", () => {
assertSpyCalls(host_call, 0);
});
});
}
});

await t.step(".reload()", async (t) => {
Expand Down Expand Up @@ -1059,6 +1102,26 @@ Deno.test("Service", async (t) => {
});
});
});

for (const [plugin_name, label] of INVALID_PLUGIN_NAMES) {
await t.step(`if the plugin name is invalid (${label})`, async (t) => {
const service = new Service(meta);
service.bind(host);
using host_call = stub(host, "call");

await t.step("rejects", async () => {
await assertRejects(
() => service.reload(plugin_name),
TypeError,
`Invalid plugin name: ${plugin_name}`,
);
});

await t.step("does not calls the host", () => {
assertSpyCalls(host_call, 0);
});
});
}
});

await t.step(".waitLoaded()", async (t) => {
Expand Down Expand Up @@ -1145,6 +1208,7 @@ Deno.test("Service", async (t) => {
using _host_call = stub(host, "call");

const actual = service.waitLoaded("dummy");
actual.catch(NOOP);
await service.close();

assertEquals(await promiseState(actual), "rejected");
Expand All @@ -1154,6 +1218,21 @@ Deno.test("Service", async (t) => {
"Service closed",
);
});

for (const [plugin_name, label] of INVALID_PLUGIN_NAMES) {
await t.step(`if the plugin name is invalid (${label})`, async (t) => {
const service = new Service(meta);
service.bind(host);

await t.step("rejects", async () => {
await assertRejects(
() => service.waitLoaded(plugin_name),
TypeError,
`Invalid plugin name: ${plugin_name}`,
);
});
});
}
});

await t.step(".interrupt()", async (t) => {
Expand Down Expand Up @@ -1296,6 +1375,21 @@ Deno.test("Service", async (t) => {
});
});
});

for (const [plugin_name, label] of INVALID_PLUGIN_NAMES) {
await t.step(`if the plugin name is invalid (${label})`, async (t) => {
const service = new Service(meta);
service.bind(host);

await t.step("rejects", async () => {
const err = await assertRejects(
() => service.dispatch(plugin_name, "test", []),
);
assert(typeof err === "string");
assertStringIncludes(err, `Invalid plugin name: ${plugin_name}`);
});
});
}
});

await t.step(".dispatchAsync()", async (t) => {
Expand Down Expand Up @@ -1522,6 +1616,40 @@ Deno.test("Service", async (t) => {
});
});
});

for (const [plugin_name, label] of INVALID_PLUGIN_NAMES) {
await t.step(`if the plugin name is invalid (${label})`, async (t) => {
const service = new Service(meta);
service.bind(host);
using host_call = stub(host, "call");

await t.step("resolves", async () => {
await service.dispatchAsync(
plugin_name,
"test",
["foo"],
"success",
"failure",
);
});

await t.step("calls 'failure' callback", () => {
const err = host_call.calls[0]?.args[2];
assert(err && typeof err === "object");
assertSpyCall(host_call, 0, {
args: [
"denops#callback#call",
"failure",
err,
],
});
assertObjectMatch(err, {
name: "TypeError",
message: `Invalid plugin name: ${plugin_name}`,
});
});
});
}
});

await t.step(".close()", async (t) => {
Expand Down
61 changes: 61 additions & 0 deletions tests/denops/runtime/functions/denops/notify_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@^1.0.1";
import { delay } from "jsr:@std/async@^1.0.1/delay";
import { INVALID_PLUGIN_NAMES } from "/denops-testdata/invalid_plugin_names.ts";
import { resolveTestDataPath } from "/denops-testdata/resolve.ts";
import { testHost } from "/denops-testutil/host.ts";
import { wait } from "/denops-testutil/wait.ts";

const ASYNC_DELAY = 100;

const scriptValid = resolveTestDataPath("dummy_valid_plugin.ts");

testHost({
name: "denops#notify()",
mode: "all",
postlude: [
"runtime plugin/denops.vim",
],
fn: async ({ host, t, stderr }) => {
let outputs: string[] = [];
stderr.pipeTo(
new WritableStream({ write: (s) => void outputs.push(s) }),
).catch(() => {});
await wait(() => host.call("eval", "denops#server#status() ==# 'running'"));
await host.call("execute", [
"let g:__test_denops_events = []",
"autocmd User DenopsPlugin* call add(g:__test_denops_events, expand('<amatch>'))",
], "");

for (const [plugin_name, label] of INVALID_PLUGIN_NAMES) {
await t.step(`if the plugin name is invalid (${label})`, async (t) => {
await t.step("does not throw an error", async () => {
await host.call("denops#notify", plugin_name, "test", ["foo"]);
});
});
}

await t.step("if the plugin is loaded", async (t) => {
// Load plugin and wait.
await host.call("execute", [
"let g:__test_denops_events = []",
`call denops#plugin#load('dummyLoaded', '${scriptValid}')`,
], "");
await wait(async () =>
(await host.call("eval", "g:__test_denops_events") as string[])
.includes("DenopsPluginPost:dummyLoaded")
);

outputs = [];
await host.call("denops#notify", "dummyLoaded", "test", ["foo"]);

await t.step("returns immediately", () => {
assertEquals(outputs, []);
});

await t.step("calls dispatcher method", async () => {
await delay(ASYNC_DELAY);
assertStringIncludes(outputs.join(""), 'This is test call: ["foo"]');
});
});
},
});
Loading

0 comments on commit d0718c2

Please sign in to comment.