Skip to content

Commit ac90ca7

Browse files
committed
WIP: eliminate "host" concept
Problem: The "remote plugin" concept is too complicated. neovim/neovim#27949 Solution: - Let the "client" also be the "host". Eliminate the separate "host" concept and related modules. - Let any node module be a "host". Any node module that imports the "neovim" package and defines method handler(s) is a "remote module". It is loaded by Nvim same as any "node client". Old architecture: nvim rplugin framework -> node: cli.js -> starts the "plugin Host" attaches itself to current node process searches for plugins and tries to load them in the node process (MULTI-TENANCY) -> plugin1 -> plugin2 -> ... New architecture: nvim vim.rplugin('node', '…/plugin1.js') -> node: neovim.cli() nvim vim.rplugin('node', '…/plugin2.js') -> node: neovim.cli() 1. A Lua plugin calls `vim.rplugin('node', '/path/to/plugin.js')`. 2. Each call to `vim.rplugin()` starts a new node process (no "multi-tenancy"). 3. plugin.js is just a normal javascript file that imports the `neovim` package. 4. plugin.js provides a "main" function. It can simply import the `neovim.cli()` util function, which handles attaching/setup. TEST CASE / DEMO: const found = findNvim({ orderBy: 'desc', minVersion: '0.9.0' }) const nvim_proc = child_process.spawn(found.matches[0].path, ['--clean', '--embed'], {}); const nvim = attach({ proc: nvim_proc }); nvim.setHandler('foo', (ev, args) => { nvim.logger.info('handled from remote module: "%s": args:%O', ev.name, args); }); nvim.callFunction('rpcrequest', [(await nvim.channelId), 'foo', [42, true, 'bar']]); 2024-03-26 16:47:35 INF handleRequest: foo 2024-03-26 16:47:35 DBG request received: foo 2024-03-26 16:47:35 INF handled from remote module: "foo": args:[ [ 42, true, 'bar' ] ]
1 parent 0253672 commit ac90ca7

File tree

10 files changed

+307
-1
lines changed

10 files changed

+307
-1
lines changed

packages/example-plugin2/fixture.js

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = 'you bet!';

packages/example-plugin2/index.js

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const required = require('./fixture');
2+
const neovim = require('neovim');
3+
4+
let nvim;
5+
6+
function hostTest(args, range) {
7+
if (args[0] === 'canhazresponse?') {
8+
throw new Error('no >:(');
9+
}
10+
11+
nvim.setLine('A line, for your troubles');
12+
13+
return 'called hostTest';
14+
}
15+
16+
function onBufEnter(filename) {
17+
return new Promise((resolve, reject) => {
18+
console.log('This is an annoying function ' + filename);
19+
resolve(filename);
20+
});
21+
}
22+
23+
function main() {
24+
nvim = neovim.cli();
25+
// Now that we successfully started, we can remove the default listener.
26+
//nvim.removeAllListeners('request');
27+
nvim.setHandler('testMethod1', hostTest);
28+
}
29+
30+
main();

packages/example-plugin2/package.json

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "@neovim/example-plugin2",
3+
"private": true,
4+
"version": "1.0.0",
5+
"description": "Test fixture for new rplugin design",
6+
"main": "index.js",
7+
"license": "MIT",
8+
"devDependencies": {}
9+
}

packages/integration-tests/__tests__/integration.test.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@ import * as fs from 'fs';
44
import * as path from 'path';
55
import * as http from 'http';
66

7+
//
8+
//
9+
// TODO: The old rplugin design is deprecated and NOT supported.
10+
// This file will be deleted.
11+
//
12+
//
13+
714
import { NeovimClient, attach, findNvim } from 'neovim';
815

9-
describe('Node host', () => {
16+
describe.skip('Node host (OLD, DELETE ME)', () => {
1017
const testdir = process.cwd();
1118
let proc: cp.ChildProcessWithoutNullStreams;
1219
let args;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/* eslint-env jest */
2+
import * as cp from 'child_process';
3+
import * as fs from 'fs';
4+
import * as path from 'path';
5+
6+
import { NeovimClient, attach, findNvim } from 'neovim';
7+
8+
/**
9+
* Runs a program and returns its output.
10+
*/
11+
async function run(cmd: string, args: string[]) {
12+
return new Promise<{ proc: ReturnType<typeof cp.spawn>, stdout: string, stderr: string}>((resolve, reject) => {
13+
const proc = cp.spawn(cmd, args, { shell: false });
14+
const rv = {
15+
proc: proc,
16+
stdout: '',
17+
stderr: '',
18+
}
19+
20+
proc.stdout.on('data', (data) => {
21+
rv.stdout += data.toString();
22+
});
23+
24+
proc.stderr.on('data', (data) => {
25+
rv.stderr += data.toString();
26+
});
27+
28+
proc.on('exit', (code_) => {
29+
resolve(rv);
30+
});
31+
32+
proc.on('error', (e) => {
33+
reject(e);
34+
});
35+
});
36+
}
37+
38+
describe('Node host2', () => {
39+
const thisDir = path.resolve(__dirname);
40+
const pluginDir = path.resolve(thisDir, '../../example-plugin2/');
41+
const pluginMain = path.resolve(pluginDir, 'index.js');
42+
43+
const testdir = process.cwd();
44+
let nvimProc: ReturnType<typeof cp.spawn>;
45+
let nvim: NeovimClient;
46+
47+
beforeAll(async () => {
48+
const minVersion = '0.9.5'
49+
const nvimInfo = findNvim({ minVersion: minVersion });
50+
const nvimPath = nvimInfo.matches[0]?.path;
51+
if (!nvimPath) {
52+
throw new Error(`nvim ${minVersion} not found`)
53+
}
54+
55+
nvimProc = cp.spawn(nvimPath, ['--clean', '-n', '--headless', '--embed'], {});
56+
nvim = attach({ proc: nvimProc });
57+
});
58+
59+
afterAll(() => {
60+
process.chdir(testdir);
61+
nvim.quit();
62+
if (nvimProc && nvimProc.connected) {
63+
nvimProc.disconnect();
64+
}
65+
});
66+
67+
beforeEach(() => {});
68+
69+
afterEach(() => {});
70+
71+
72+
/**
73+
* From the Nvim process, starts a new "node …/plugin/index.js" RPC job (that
74+
* is, a node "plugin host", aka an Nvim node client).
75+
*/
76+
async function newPluginChan() {
77+
const luacode = `
78+
-- "node …/plugin/index.js"
79+
local argv = {'${process.argv0}', '${pluginMain}'}
80+
local chan = vim.fn.jobstart(argv, { rpc = true, stderr_buffered = true })
81+
return chan
82+
`
83+
return await nvim.lua(luacode);
84+
}
85+
86+
it('`node plugin.js --version` prints node-client version', async () => {
87+
//process.chdir(thisDir);
88+
const proc = await run(process.argv0, [pluginMain, '--version']);
89+
// "5.1.1-dev.0\n"
90+
expect(proc.stdout).toMatch(/\d+\.\d+\.\d+/);
91+
92+
proc.proc.kill('SIGKILL');
93+
});
94+
95+
it('responds to "poll" with "ok"', async () => {
96+
// See also the old provider#Poll() function.
97+
98+
// From Nvim, start an "node …/plugin/index.js" RPC job.
99+
// Then use that channel to call methods on the remote plugin.
100+
const chan = await newPluginChan();
101+
const rv = await nvim.lua(`return vim.rpcrequest(..., 'poll')`, [ chan ]);
102+
103+
expect(rv).toEqual('ok');
104+
});
105+
106+
//it('responds to "nvim_xx" methods', async () => {
107+
// // This is just a happy accident of the fact that Nvim plugin host === client.
108+
// const chan = await newPluginChan();
109+
// const rv = await nvim.lua(`return vim.rpcrequest(..., 'nvim_eval', '1 + 3')`, [ chan ]);
110+
// expect(rv).toEqual(3);
111+
//});
112+
113+
it('responds to custom, plugin-defined methods', async () => {
114+
// The "foo" method is defined in …/example-plugin2/index.js.
115+
116+
const chan = await newPluginChan();
117+
const rv = await nvim.lua(`return vim.rpcrequest(..., 'testMethod1', {})`, [ chan ]);
118+
119+
expect(rv).toEqual('called hostTest');
120+
});
121+
122+
// TODO
123+
//it('Lua plugin can define autocmds/functions that call the remote plugin', async () => {
124+
// // JSHostTestCmd
125+
// // BufEnter
126+
//});
127+
});
128+

packages/neovim/src/api/client.ts

+55
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,32 @@ import { Buffer } from './Buffer';
99

1010
const REGEX_BUF_EVENT = /nvim_buf_(.*)_event/;
1111

12+
export interface Response {
13+
send(resp: any, isError?: boolean): void;
14+
}
15+
1216
export class NeovimClient extends Neovim {
1317
protected requestQueue: any[];
1418

19+
/**
20+
* Handlers for custom (non "nvim_") methods registered by the remote module.
21+
* These handle requests from the Nvim peer.
22+
*/
23+
public handlers: { [index: string]: (args: any[], event: { name: string }) => any } = {};
24+
1525
private transportAttached: boolean;
1626

1727
private _channelId?: number;
1828

1929
private attachedBuffers: Map<string, Map<string, Function[]>> = new Map();
2030

31+
/**
32+
* Defines a handler for incoming RPC request method/notification.
33+
*/
34+
setHandler(method: string, fn: (args: any[], event: { name: string }) => any) {
35+
this.handlers[method] = fn;
36+
}
37+
2138
constructor(options: { transport?: Transport; logger?: Logger } = {}) {
2239
// Neovim has no `data` or `metadata`
2340
super({
@@ -44,6 +61,41 @@ export class NeovimClient extends Neovim {
4461
this.setupTransport();
4562
}
4663

64+
/**
65+
* The "client" is also the "host". https://github.com/neovim/neovim/issues/27949
66+
*/
67+
async handleRequest2(method: string, args: any[], res: Response) {
68+
this.logger.debug('request received: %s', method);
69+
// 'poll' and 'specs' are requests from Nvim internals. Else we dispatch to registered remote module methods (if any).
70+
if (method === 'poll') {
71+
// Handshake for Nvim.
72+
res.send('ok');
73+
//} else if (method.startsWith('nvim_')) {
74+
// // Let base class handle it.
75+
// this.request(method, args);
76+
} else {
77+
const handler = this.handlers[method];
78+
if (!handler) {
79+
const msg = `node-client: missing handler for "${method}"`;
80+
this.logger.error(msg);
81+
res.send(msg, true);
82+
}
83+
84+
try {
85+
this.logger.debug('found handler: %s: %O', method, handler);
86+
const plugResult = await handler(args, { name: method });
87+
res.send(
88+
!plugResult || typeof plugResult === 'undefined' ? null : plugResult
89+
);
90+
} catch (e) {
91+
const err = e as Error;
92+
const msg = `node-client: failed to handle request: "${method}": ${err.message}`;
93+
this.logger.error(msg);
94+
res.send(err.toString(), true);
95+
}
96+
}
97+
}
98+
4799
get isApiReady(): boolean {
48100
return this.transportAttached && this._channelId !== undefined;
49101
}
@@ -188,6 +240,9 @@ export class NeovimClient extends Neovim {
188240

189241
this._channelId = channelId;
190242

243+
// XXX
244+
this.on('request', this.handleRequest2);
245+
191246
// register the non-queueing handlers
192247
// dequeue any pending RPCs
193248
this.requestQueue.forEach(pending => {

packages/neovim/src/cli.ts

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { spawnSync } from 'child_process';
2+
import { attach } from './attach';
3+
4+
// node <current script> <rest of args>
5+
const [, , ...args] = process.argv;
6+
7+
if (args[0] === '--version') {
8+
// eslint-disable-next-line global-require
9+
const pkg = require('../package.json');
10+
// eslint-disable-next-line no-console
11+
console.log(pkg.version);
12+
process.exit(0);
13+
}
14+
15+
// "21.6.1" => "21"
16+
const nodeMajorVersionStr = process.versions.node.replace(/\..*/, '')
17+
const nodeMajorVersion = Number.parseInt(nodeMajorVersionStr ?? '0')
18+
19+
if (
20+
process.env.NVIM_NODE_HOST_DEBUG &&
21+
nodeMajorVersion >= 8 &&
22+
process.execArgv.every(token => token !== '--inspect-brk')
23+
) {
24+
const childHost = spawnSync(
25+
process.execPath,
26+
process.execArgv.concat(['--inspect-brk']).concat(process.argv.slice(1)),
27+
{ stdio: 'inherit' }
28+
);
29+
process.exit(childHost.status ?? undefined);
30+
}
31+
32+
export interface Response {
33+
send(resp: any, isError?: boolean): void;
34+
}
35+
36+
process.on('unhandledRejection', (reason, p) => {
37+
process.stderr.write(`Unhandled Rejection at: ${p} reason: ${reason}\n`);
38+
});
39+
40+
// "The client *is* the host... The client *is* the host..."
41+
//
42+
// "Main" entrypoint for any Nvim remote plugin. It implements the Nvim remote
43+
// plugin specification:
44+
// - Attaches self to incoming RPC channel.
45+
// - Responds to "poll" with "ok".
46+
// - TODO: "specs"?
47+
export function cli() {
48+
try {
49+
// Reverse stdio because it's from the perspective of Nvim.
50+
const nvim = attach({ reader: process.stdin, writer: process.stdout });
51+
nvim.logger.debug('host.start');
52+
53+
return nvim;
54+
} catch (e) {
55+
const err = e as Error;
56+
process.stderr.write(`failed to start Nvim plugin host: ${err.name}: ${err.message}\n`);
57+
58+
return undefined;
59+
}
60+
}

packages/neovim/src/host/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ export interface Response {
66
send(resp: any, isError?: boolean): void;
77
}
88

9+
/**
10+
* @deprecated Eliminate the "host" concept. https://github.com/neovim/neovim/issues/27949
11+
*/
912
export class Host {
1013
public loaded: { [index: string]: NvimPlugin };
1114

packages/neovim/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { attach } from './attach';
2+
export { cli } from './cli';
23
export { Neovim, NeovimClient, Buffer, Tabpage, Window } from './api/index';
34
export { Plugin, Function, Autocmd, Command } from './plugin';
45
export { NvimPlugin } from './host/NvimPlugin';

test.lua

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
2+
-- See also the old provider#Poll() function.
3+
local function doit()
4+
local job = {rpc = true, stderr_buffered =true}
5+
local argv = {'node', 'packages/example-plugin2/index.js'}
6+
local chan = vim.fn.jobstart(argv, job)
7+
local pollresult = vim.rpcrequest(chan, 'poll')
8+
-- assert(pollresult == 'ok')
9+
vim.print(pollresult)
10+
end
11+
12+
doit()

0 commit comments

Comments
 (0)