diff --git a/packages/example-plugin2/fixture.js b/packages/example-plugin2/fixture.js new file mode 100644 index 00000000..d72b480b --- /dev/null +++ b/packages/example-plugin2/fixture.js @@ -0,0 +1 @@ +module.exports = 'you bet!'; diff --git a/packages/example-plugin2/index.js b/packages/example-plugin2/index.js new file mode 100644 index 00000000..6755ddd9 --- /dev/null +++ b/packages/example-plugin2/index.js @@ -0,0 +1,30 @@ +const required = require('./fixture'); +const neovim = require('neovim'); + +let nvim; + +function hostTest(args, range) { + if (args[0] === 'canhazresponse?') { + throw new Error('no >:('); + } + + nvim.setLine('A line, for your troubles'); + + return 'called hostTest'; +} + +function onBufEnter(filename) { + return new Promise((resolve, reject) => { + console.log('This is an annoying function ' + filename); + resolve(filename); + }); +} + +function main() { + nvim = neovim.cli(); + // Now that we successfully started, we can remove the default listener. + //nvim.removeAllListeners('request'); + nvim.setHandler('testMethod1', hostTest); +} + +main(); diff --git a/packages/example-plugin2/package.json b/packages/example-plugin2/package.json new file mode 100644 index 00000000..04e8020f --- /dev/null +++ b/packages/example-plugin2/package.json @@ -0,0 +1,9 @@ +{ + "name": "@neovim/example-plugin2", + "private": true, + "version": "1.0.0", + "description": "Test fixture for new rplugin design", + "main": "index.js", + "license": "MIT", + "devDependencies": {} +} diff --git a/packages/integration-tests/__tests__/integration.test.ts b/packages/integration-tests/__tests__/integration.test.ts index 47231301..126cee1c 100644 --- a/packages/integration-tests/__tests__/integration.test.ts +++ b/packages/integration-tests/__tests__/integration.test.ts @@ -4,9 +4,16 @@ import * as fs from 'fs'; import * as path from 'path'; import * as http from 'http'; +// +// +// TODO: The old rplugin design is deprecated and NOT supported. +// This file will be deleted. +// +// + import { NeovimClient, attach, findNvim } from 'neovim'; -describe('Node host', () => { +describe.skip('Node host (OLD, DELETE ME)', () => { const testdir = process.cwd(); let proc: cp.ChildProcessWithoutNullStreams; let args; diff --git a/packages/integration-tests/__tests__/rplugin2.test.ts b/packages/integration-tests/__tests__/rplugin2.test.ts new file mode 100644 index 00000000..f226d047 --- /dev/null +++ b/packages/integration-tests/__tests__/rplugin2.test.ts @@ -0,0 +1,127 @@ +/* eslint-env jest */ +import * as cp from 'child_process'; +import * as path from 'path'; + +import { NeovimClient, attach, findNvim } from 'neovim'; + +/** + * Runs a program and returns its output. + */ +async function run(cmd: string, args: string[]) { + return new Promise<{ proc: ReturnType, stdout: string, stderr: string}>((resolve, reject) => { + const proc = cp.spawn(cmd, args, { shell: false }); + const rv = { + proc: proc, + stdout: '', + stderr: '', + } + + proc.stdout.on('data', (data) => { + rv.stdout += data.toString(); + }); + + proc.stderr.on('data', (data) => { + rv.stderr += data.toString(); + }); + + proc.on('exit', (code_) => { + resolve(rv); + }); + + proc.on('error', (e) => { + reject(e); + }); + }); +} + +describe('Node host2', () => { + const thisDir = path.resolve(__dirname); + const pluginDir = path.resolve(thisDir, '../../example-plugin2/'); + const pluginMain = path.resolve(pluginDir, 'index.js').replace(/\\/g, '/'); + + const testdir = process.cwd(); + let nvimProc: ReturnType; + let nvim: NeovimClient; + + beforeAll(async () => { + const minVersion = '0.9.5' + const nvimInfo = findNvim({ minVersion: minVersion }); + const nvimPath = nvimInfo.matches[0]?.path; + if (!nvimPath) { + throw new Error(`nvim ${minVersion} not found`) + } + + nvimProc = cp.spawn(nvimPath, ['--clean', '-n', '--headless', '--embed'], {}); + nvim = attach({ proc: nvimProc }); + }); + + afterAll(() => { + process.chdir(testdir); + nvim.quit(); + if (nvimProc && nvimProc.connected) { + nvimProc.disconnect(); + } + }); + + beforeEach(() => {}); + + afterEach(() => {}); + + + /** + * From the Nvim process, starts a new "node …/plugin/index.js" RPC job (that + * is, a node "plugin host", aka an Nvim node client). + */ + async function newPluginChan() { + const nodePath = process.argv0.replace(/\\/g, '/'); + const luacode = ` + -- "node …/plugin/index.js" + local argv = { [[${nodePath}]], [[${pluginMain}]] } + local chan = vim.fn.jobstart(argv, { rpc = true, stderr_buffered = true }) + return chan + ` + return await nvim.lua(luacode); + } + + it('`node plugin.js --version` prints node-client version', async () => { + //process.chdir(thisDir); + const proc = await run(process.argv0, [pluginMain, '--version']); + // "5.1.1-dev.0\n" + expect(proc.stdout).toMatch(/\d+\.\d+\.\d+/); + + proc.proc.kill('SIGKILL'); + }); + + it('responds to "poll" with "ok"', async () => { + // See also the old provider#Poll() function. + + // From Nvim, start an "node …/plugin/index.js" RPC job. + // Then use that channel to call methods on the remote plugin. + const chan = await newPluginChan(); + const rv = await nvim.lua(`return vim.rpcrequest(..., 'poll')`, [ chan ]); + + expect(rv).toEqual('ok'); + }); + + //it('responds to "nvim_xx" methods', async () => { + // // This is just a happy accident of the fact that Nvim plugin host === client. + // const chan = await newPluginChan(); + // const rv = await nvim.lua(`return vim.rpcrequest(..., 'nvim_eval', '1 + 3')`, [ chan ]); + // expect(rv).toEqual(3); + //}); + + it('responds to custom, plugin-defined methods', async () => { + const chan = await newPluginChan(); + // The "testMethod1" function is defined in …/example-plugin2/index.js. + const rv = await nvim.lua(`return vim.rpcrequest(..., 'testMethod1', {})`, [ chan ]); + + expect(rv).toEqual('called hostTest'); + }); + + // TODO + //it('Lua plugin can define autocmds/functions that call the remote plugin', async () => { + // // JSHostTestCmd + // // BufEnter + //}); +}); + diff --git a/packages/neovim/src/api/client.ts b/packages/neovim/src/api/client.ts index 672dff9e..8ab1ea10 100644 --- a/packages/neovim/src/api/client.ts +++ b/packages/neovim/src/api/client.ts @@ -2,7 +2,7 @@ * Handles attaching transport */ import { Logger } from '../utils/logger'; -import { Transport } from '../utils/transport'; +import { Response, Transport } from '../utils/transport'; import { VimValue } from '../types/VimValue'; import { Neovim } from './Neovim'; import { Buffer } from './Buffer'; @@ -12,12 +12,30 @@ const REGEX_BUF_EVENT = /nvim_buf_(.*)_event/; export class NeovimClient extends Neovim { protected requestQueue: any[]; + /** + * Handlers for custom (non "nvim_") methods registered by the remote module. + * These handle requests from the Nvim peer. + */ + public handlers: { + [index: string]: (args: any[], event: { name: string }) => any; + } = {}; + private transportAttached: boolean; private _channelId?: number; private attachedBuffers: Map> = new Map(); + /** + * Defines a handler for incoming RPC request method/notification. + */ + setHandler( + method: string, + fn: (args: any[], event: { name: string }) => any + ) { + this.handlers[method] = fn; + } + constructor(options: { transport?: Transport; logger?: Logger } = {}) { // Neovim has no `data` or `metadata` super({ @@ -66,7 +84,7 @@ export class NeovimClient extends Neovim { handleRequest( method: string, args: VimValue[], - resp: any, + resp: Response, ...restArgs: any[] ) { // If neovim API is not generated yet and we are not handle a 'specs' request diff --git a/packages/neovim/src/cli.ts b/packages/neovim/src/cli.ts new file mode 100644 index 00000000..06ea393f --- /dev/null +++ b/packages/neovim/src/cli.ts @@ -0,0 +1,100 @@ +import { spawnSync } from 'node:child_process'; +import { attach } from './attach'; + +let nvim: ReturnType; + +// node +const [, , ...args] = process.argv; + +if (args[0] === '--version') { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const pkg = require('../package.json'); + // eslint-disable-next-line no-console + console.log(pkg.version); + process.exit(0); +} + +// "21.6.1" => "21" +const nodeMajorVersionStr = process.versions.node.replace(/\..*/, ''); +const nodeMajorVersion = Number.parseInt(nodeMajorVersionStr ?? '0', 10); + +if ( + process.env.NVIM_NODE_HOST_DEBUG && + nodeMajorVersion >= 8 && + process.execArgv.every(token => token !== '--inspect-brk') +) { + const childHost = spawnSync( + process.execPath, + process.execArgv.concat(['--inspect-brk']).concat(process.argv.slice(1)), + { stdio: 'inherit' } + ); + process.exit(childHost.status ?? undefined); +} + +export interface Response { + send(resp: any, isError?: boolean): void; +} + +process.on('unhandledRejection', (reason, p) => { + process.stderr.write(`Unhandled Rejection at: ${p} reason: ${reason}\n`); +}); + +/** + * The "client" is also the "host". https://github.com/neovim/neovim/issues/27949 + */ +async function handleRequest(method: string, args: any[], res: Response) { + nvim.logger.debug('request received: %s', method); + // 'poll' and 'specs' are requests from Nvim internals. Else we dispatch to registered remote module methods (if any). + if (method === 'poll') { + // Handshake for Nvim. + res.send('ok'); + // } else if (method.startsWith('nvim_')) { + // // Let base class handle it. + // nvim.request(method, args); + } else { + const handler = nvim.handlers[method]; + if (!handler) { + const msg = `node-client: missing handler for "${method}"`; + nvim.logger.error(msg); + res.send(msg, true); + } + + try { + nvim.logger.debug('found handler: %s: %O', method, handler); + const plugResult = await handler(args, { name: method }); + res.send( + !plugResult || typeof plugResult === 'undefined' ? null : plugResult + ); + } catch (e) { + const err = e as Error; + const msg = `node-client: failed to handle request: "${method}": ${err.message}`; + nvim.logger.error(msg); + res.send(err.toString(), true); + } + } +} + +// "The client *is* the host... The client *is* the host..." +// +// "Main" entrypoint for any Nvim remote plugin. It implements the Nvim remote +// plugin specification: +// - Attaches self to incoming RPC channel. +// - Responds to "poll" with "ok". +// - TODO: "specs"? +export function cli() { + try { + // Reverse stdio because it's from the perspective of Nvim. + nvim = attach({ reader: process.stdin, writer: process.stdout }); + nvim.logger.debug('host.start'); + nvim.on('request', handleRequest); + + return nvim; + } catch (e) { + const err = e as Error; + process.stderr.write( + `failed to start Nvim plugin host: ${err.name}: ${err.message}\n` + ); + + return undefined; + } +} diff --git a/packages/neovim/src/host/index.ts b/packages/neovim/src/host/index.ts index b5a07a81..8be12d7d 100644 --- a/packages/neovim/src/host/index.ts +++ b/packages/neovim/src/host/index.ts @@ -6,6 +6,9 @@ export interface Response { send(resp: any, isError?: boolean): void; } +/** + * @deprecated Eliminate the "host" concept. https://github.com/neovim/neovim/issues/27949 + */ export class Host { public loaded: { [index: string]: NvimPlugin }; diff --git a/packages/neovim/src/index.ts b/packages/neovim/src/index.ts index 6adf1fa2..0a398dfa 100644 --- a/packages/neovim/src/index.ts +++ b/packages/neovim/src/index.ts @@ -1,4 +1,5 @@ export { attach } from './attach'; +export { cli } from './cli'; export { Neovim, NeovimClient, Buffer, Tabpage, Window } from './api/index'; export { Plugin, Function, Autocmd, Command } from './plugin'; export { NvimPlugin } from './host/NvimPlugin'; diff --git a/packages/neovim/src/utils/transport.ts b/packages/neovim/src/utils/transport.ts index fdc390e6..0f531f77 100644 --- a/packages/neovim/src/utils/transport.ts +++ b/packages/neovim/src/utils/transport.ts @@ -12,7 +12,7 @@ import { } from '@msgpack/msgpack'; import { Metadata } from '../api/types'; -class Response { +export class Response { private requestId: number; private sent!: boolean;