Skip to content

Commit 01eec52

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". Story: - The value in rplugins is: 1. it finds the interpreter on the system 2. it figures out how to invoke the main script with the interpreter 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 01eec52

File tree

10 files changed

+300
-4
lines changed

10 files changed

+300
-4
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,127 @@
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+
const chan = await newPluginChan();
115+
// The "testMethod1" function is defined in …/example-plugin2/index.js.
116+
const rv = await nvim.lua(`return vim.rpcrequest(..., 'testMethod1', {})`, [ chan ]);
117+
118+
expect(rv).toEqual('called hostTest');
119+
});
120+
121+
// TODO
122+
//it('Lua plugin can define autocmds/functions that call the remote plugin', async () => {
123+
// // JSHostTestCmd
124+
// // BufEnter
125+
//});
126+
});
127+

packages/neovim/src/api/client.ts

+20-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Handles attaching transport
33
*/
44
import { Logger } from '../utils/logger';
5-
import { Transport } from '../utils/transport';
5+
import { Response, Transport } from '../utils/transport';
66
import { VimValue } from '../types/VimValue';
77
import { Neovim } from './Neovim';
88
import { Buffer } from './Buffer';
@@ -12,12 +12,30 @@ const REGEX_BUF_EVENT = /nvim_buf_(.*)_event/;
1212
export class NeovimClient extends Neovim {
1313
protected requestQueue: any[];
1414

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

1725
private _channelId?: number;
1826

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

29+
/**
30+
* Defines a handler for incoming RPC request method/notification.
31+
*/
32+
setHandler(
33+
method: string,
34+
fn: (args: any[], event: { name: string }) => any
35+
) {
36+
this.handlers[method] = fn;
37+
}
38+
2139
constructor(options: { transport?: Transport; logger?: Logger } = {}) {
2240
// Neovim has no `data` or `metadata`
2341
super({
@@ -66,7 +84,7 @@ export class NeovimClient extends Neovim {
6684
handleRequest(
6785
method: string,
6886
args: VimValue[],
69-
resp: any,
87+
resp: Response,
7088
...restArgs: any[]
7189
) {
7290
// If neovim API is not generated yet and we are not handle a 'specs' request

packages/neovim/src/cli.ts

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { spawnSync } from 'node:child_process';
2+
import { attach } from './attach';
3+
4+
let nvim: ReturnType<typeof attach>;
5+
6+
// node <current script> <rest of args>
7+
const [, , ...args] = process.argv;
8+
9+
if (args[0] === '--version') {
10+
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
11+
const pkg = require('../package.json');
12+
// eslint-disable-next-line no-console
13+
console.log(pkg.version);
14+
process.exit(0);
15+
}
16+
17+
// "21.6.1" => "21"
18+
const nodeMajorVersionStr = process.versions.node.replace(/\..*/, '');
19+
const nodeMajorVersion = Number.parseInt(nodeMajorVersionStr ?? '0', 10);
20+
21+
if (
22+
process.env.NVIM_NODE_HOST_DEBUG &&
23+
nodeMajorVersion >= 8 &&
24+
process.execArgv.every(token => token !== '--inspect-brk')
25+
) {
26+
const childHost = spawnSync(
27+
process.execPath,
28+
process.execArgv.concat(['--inspect-brk']).concat(process.argv.slice(1)),
29+
{ stdio: 'inherit' }
30+
);
31+
process.exit(childHost.status ?? undefined);
32+
}
33+
34+
export interface Response {
35+
send(resp: any, isError?: boolean): void;
36+
}
37+
38+
process.on('unhandledRejection', (reason, p) => {
39+
process.stderr.write(`Unhandled Rejection at: ${p} reason: ${reason}\n`);
40+
});
41+
42+
/**
43+
* The "client" is also the "host". https://github.com/neovim/neovim/issues/27949
44+
*/
45+
async function handleRequest(method: string, args: any[], res: Response) {
46+
nvim.logger.debug('request received: %s', method);
47+
// 'poll' and 'specs' are requests from Nvim internals. Else we dispatch to registered remote module methods (if any).
48+
if (method === 'poll') {
49+
// Handshake for Nvim.
50+
res.send('ok');
51+
// } else if (method.startsWith('nvim_')) {
52+
// // Let base class handle it.
53+
// nvim.request(method, args);
54+
} else {
55+
const handler = nvim.handlers[method];
56+
if (!handler) {
57+
const msg = `node-client: missing handler for "${method}"`;
58+
nvim.logger.error(msg);
59+
res.send(msg, true);
60+
}
61+
62+
try {
63+
nvim.logger.debug('found handler: %s: %O', method, handler);
64+
const plugResult = await handler(args, { name: method });
65+
res.send(
66+
!plugResult || typeof plugResult === 'undefined' ? null : plugResult
67+
);
68+
} catch (e) {
69+
const err = e as Error;
70+
const msg = `node-client: failed to handle request: "${method}": ${err.message}`;
71+
nvim.logger.error(msg);
72+
res.send(err.toString(), true);
73+
}
74+
}
75+
}
76+
77+
// "The client *is* the host... The client *is* the host..."
78+
//
79+
// "Main" entrypoint for any Nvim remote plugin. It implements the Nvim remote
80+
// plugin specification:
81+
// - Attaches self to incoming RPC channel.
82+
// - Responds to "poll" with "ok".
83+
// - TODO: "specs"?
84+
export function cli() {
85+
try {
86+
// Reverse stdio because it's from the perspective of Nvim.
87+
nvim = attach({ reader: process.stdin, writer: process.stdout });
88+
nvim.logger.debug('host.start');
89+
nvim.on('request', handleRequest);
90+
91+
return nvim;
92+
} catch (e) {
93+
const err = e as Error;
94+
process.stderr.write(
95+
`failed to start Nvim plugin host: ${err.name}: ${err.message}\n`
96+
);
97+
98+
return undefined;
99+
}
100+
}

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';

packages/neovim/src/utils/transport.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from '@msgpack/msgpack';
1313
import { Metadata } from '../api/types';
1414

15-
class Response {
15+
export class Response {
1616
private requestId: number;
1717

1818
private sent!: boolean;

0 commit comments

Comments
 (0)