or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

api-client.mdbuffer.mdconnection.mdindex.mdplugin-development.mdwindow-tabpage.md
tile.json

plugin-development.mddocs/

Plugin Development

Neovim remote plugins allow you to extend Neovim functionality using JavaScript/TypeScript. This package provides both decorator-based and class-based approaches for creating plugins with commands, functions, and autocommands.

Capabilities

Plugin Decorators

/**
 * Class decorator for marking a class as a Neovim plugin.
 * Automatically processes method decorators (@Command, @Function, @Autocmd).
 *
 * @param target - Plugin class
 * @returns Decorated class
 */
function Plugin(target: any): any;

/**
 * Class decorator with options for marking a class as a Neovim plugin.
 *
 * @param options - Plugin options
 * @returns Decorator function
 */
function Plugin(options: PluginDecoratorOptions): (target: any) => any;

interface PluginDecoratorOptions {
  /** Enable development mode (clears module cache on reload) */
  dev?: boolean;
}

Usage Examples:

import { Plugin, Command, Function as NvimFunction, Autocmd, NvimPlugin } from 'neovim';

// Basic plugin
@Plugin
export class MyPlugin {
  // Plugin implementation
}

// Plugin with development mode
@Plugin({ dev: true })
export class DevPlugin {
  // Development mode - module reloaded on each invocation
}

// Plugin without decorator (functional style)
export default function(plugin: NvimPlugin) {
  // Manual registration
}

Command Decorator

/**
 * Method decorator for registering Neovim commands.
 * Commands are invoked from Neovim with :CommandName
 *
 * @param name - Command name (will be invoked as :Name)
 * @param options - Command options
 * @returns Method decorator
 */
function Command(name: string, options?: CommandOptions): MethodDecorator;

interface CommandOptions {
  /** Force synchronous execution (default: false) */
  sync?: boolean;
  /** Range specification (see :help :command-range) */
  range?: string;
  /** Number of arguments: '0', '1', '*', '?', '+' (see :help :command-nargs) */
  nargs?: string;
  /** Completion type (see :help :command-complete) */
  complete?: string;
}

Usage Examples:

import { Plugin, Command } from 'neovim';

@Plugin
export class CommandExamples {
  // Simple command (async)
  @Command('HelloWorld')
  async helloWorld() {
    console.log('Hello from command!');
  }

  // Synchronous command
  @Command('SyncCommand', { sync: true })
  syncCommand() {
    return 'Immediate result';
  }

  // Command with arguments
  @Command('Echo', { nargs: '*' })
  async echo(args: string[]) {
    const message = args.join(' ');
    console.log('Echo:', message);
  }

  // Command with specific arg count
  @Command('Add', { nargs: '2' })
  async add(args: string[]) {
    const [a, b] = args.map(Number);
    return a + b;
  }

  // Command with range
  @Command('ProcessRange', { range: '', nargs: '0' })
  async processRange(args: string[], range: [number, number]) {
    const [start, end] = range;
    console.log(`Processing lines ${start} to ${end}`);
  }

  // Command with completion
  @Command('OpenFile', { nargs: '1', complete: 'file' })
  async openFile(args: string[]) {
    const filename = args[0];
    console.log('Opening:', filename);
  }
}

Function Decorator

/**
 * Method decorator for registering Neovim functions.
 * Functions are invoked from Neovim with :call FunctionName(args)
 *
 * @param name - Function name (will be invoked as FunctionName())
 * @param options - Function options
 * @returns Method decorator
 */
function Function(name: string, options?: NvimFunctionOptions): MethodDecorator;

interface NvimFunctionOptions {
  /** Force synchronous execution (default: false) */
  sync?: boolean;
  /** Range parameters */
  range?: [number, number];
  /** VimL expression to evaluate and pass as argument */
  eval?: string;
}

Usage Examples:

import { Plugin, Function as NvimFunction } from 'neovim';

@Plugin
export class FunctionExamples {
  // Simple function
  @NvimFunction('GetTimestamp')
  async getTimestamp() {
    return Date.now();
  }

  // Synchronous function
  @NvimFunction('Double', { sync: true })
  double(args: number[]) {
    return args[0] * 2;
  }

  // Function with eval
  @NvimFunction('CurrentLine', { eval: 'line(".")' })
  async currentLine(args: [number]) {
    const lineNum = args[0];
    console.log('Current line:', lineNum);
    return lineNum;
  }

  // Function processing multiple args
  @NvimFunction('Sum')
  async sum(args: number[]) {
    return args.reduce((acc, val) => acc + val, 0);
  }

  // Function with range
  @NvimFunction('ProcessRange', { range: [0, 0] })
  async processRange(args: any[], range: [number, number]) {
    const [start, end] = range;
    return `Lines ${start} to ${end}`;
  }
}

// Invoke from Neovim:
// :call GetTimestamp()
// :echo Double(21)
// :let result = Sum(1, 2, 3, 4)

Autocmd Decorator

/**
 * Method decorator for registering autocommands.
 * Autocommands are triggered automatically by Neovim events.
 *
 * @param name - Event name (e.g., 'BufEnter', 'BufWritePre')
 * @param options - Autocmd options
 * @returns Method decorator
 */
function Autocmd(name: string, options: AutocmdOptions): MethodDecorator;

interface AutocmdOptions {
  /** File pattern (required, see :help autocmd-pattern) */
  pattern: string;
  /** VimL expression to evaluate and pass as argument */
  eval?: string;
  /** Force synchronous execution (default: false) */
  sync?: boolean;
}

Usage Examples:

import { Plugin, Autocmd, NvimPlugin } from 'neovim';

@Plugin
export class AutocmdExamples {
  private nvim: Neovim;

  constructor(nvim: Neovim) {
    this.nvim = nvim;
  }

  // Trigger on buffer enter
  @Autocmd('BufEnter', { pattern: '*' })
  async onBufEnter(filename: string) {
    console.log('Entered buffer:', filename);
  }

  // Trigger for specific file types
  @Autocmd('BufEnter', { pattern: '*.ts' })
  async onTypescriptFile(filename: string) {
    console.log('TypeScript file:', filename);
    await this.nvim.command('set filetype=typescript');
  }

  // Trigger before save
  @Autocmd('BufWritePre', { pattern: '*' })
  async beforeSave(filename: string) {
    console.log('About to save:', filename);
    // Auto-format, lint, etc.
  }

  // Trigger after save
  @Autocmd('BufWritePost', { pattern: '*.js' })
  async afterJsSave(filename: string) {
    console.log('Saved JavaScript file:', filename);
    // Run tests, build, etc.
  }

  // Synchronous autocmd
  @Autocmd('BufReadPre', { pattern: '*.md', sync: true })
  beforeReadMarkdown(filename: string) {
    console.log('Reading markdown:', filename);
  }

  // Autocmd with eval
  @Autocmd('CursorMoved', { pattern: '*', eval: 'line(".")' })
  async onCursorMoved(args: [number]) {
    const lineNum = args[0];
    // React to cursor position
  }
}

NvimPlugin Class

/**
 * Plugin container class that manages command, function, and autocmd handlers.
 * Used for manual plugin registration without decorators.
 */
class NvimPlugin {
  /** Path to plugin file */
  filename: string;

  /** Neovim instance */
  nvim: Neovim;

  /** Plugin instance object */
  instance: any;

  /** Development mode flag */
  dev: boolean;

  /** Always reinitialize flag */
  alwaysInit: boolean;

  /** Registered autocmd handlers */
  autocmds: { [index: string]: Handler };

  /** Registered command handlers */
  commands: { [index: string]: Handler };

  /** Registered function handlers */
  functions: { [index: string]: Handler };

  /** All handler specifications */
  specs: Spec[];

  /** Whether to cache the plugin module */
  shouldCacheModule: boolean;

  /**
   * Creates a new plugin instance.
   *
   * @param filename - Path to the plugin file
   * @param plugin - Plugin class or factory function
   * @param nvim - Neovim instance
   */
  constructor(filename: string, plugin: any, nvim: Neovim);

  /**
   * Set plugin options.
   *
   * @param options - Plugin options
   */
  setOptions(options: NvimPluginOptions): void;

  /**
   * Register an autocommand handler.
   *
   * @param name - Event name
   * @param fn - Handler function or [object, method]
   * @param options - Autocmd options
   */
  registerAutocmd(name: string, fn: Function | [any, Function], options: AutocmdOptions): void;

  /**
   * Register a command handler.
   *
   * @param name - Command name
   * @param fn - Handler function or [object, method]
   * @param options - Command options
   */
  registerCommand(name: string, fn: Function | [any, Function], options?: CommandOptions): void;

  /**
   * Register a function handler.
   *
   * @param name - Function name
   * @param fn - Handler function or [object, method]
   * @param options - Function options
   */
  registerFunction(name: string, fn: Function | [any, Function], options?: NvimFunctionOptions): void;

  /**
   * Handle incoming RPC request.
   *
   * @param name - Handler name
   * @param type - Handler type ('autocmd', 'command', 'function')
   * @param args - Handler arguments
   * @returns Handler result
   */
  handleRequest(name: string, type: string, args: any[]): Promise<any>;
}

interface NvimPluginOptions {
  /** Development mode (reload on each invocation) */
  dev?: boolean;
  /** Always reinitialize plugin instance */
  alwaysInit?: boolean;
}

interface Handler {
  fn: Function;
  spec: Spec;
}

interface Spec {
  type: string;
  name: string;
  sync?: boolean;
  opts?: object;
}

Usage Examples:

import { NvimPlugin, Neovim } from 'neovim';

// Functional plugin style (no decorators)
export default function(plugin: NvimPlugin) {
  // Set options
  plugin.setOptions({ dev: true });

  // Register command
  plugin.registerCommand('MyCommand', async (args: string[]) => {
    console.log('Command executed with:', args);
  }, { nargs: '*' });

  // Register function
  plugin.registerFunction('MyFunction', async (args: any[]) => {
    return args[0] * 2;
  }, { sync: false });

  // Register autocmd
  plugin.registerAutocmd('BufEnter', async (filename: string) => {
    console.log('Buffer entered:', filename);
  }, { pattern: '*.js' });

  // Access Neovim instance
  const nvim = plugin.nvim;

  // Register with object method
  const handler = {
    async process(args: any[]) {
      const buf = await nvim.buffer;
      await buf.append(['Processed!']);
    }
  };

  plugin.registerCommand('Process', [handler, handler.process], { nargs: '0' });
}

Load Plugin Function

/**
 * Load a plugin from file path.
 * Used by plugin host to discover and load remote plugins.
 *
 * @param filename - Path to plugin file or directory
 * @param nvim - Neovim instance
 * @param options - Load options
 * @returns NvimPlugin instance or null on error
 */
function loadPlugin(filename: string, nvim: Neovim, options?: LoadPluginOptions): NvimPlugin | null;

interface LoadPluginOptions {
  /** Whether to cache the module (default: true) */
  cache?: boolean;
}

Usage Examples:

import { loadPlugin, attach } from 'neovim';
import * as child_process from 'node:child_process';

const nvim_proc = child_process.spawn('nvim', ['--embed']);
const nvim = attach({ proc: nvim_proc });

// Load plugin
const plugin = loadPlugin('./my-plugin.js', nvim);

if (plugin) {
  console.log('Plugin loaded:', plugin.filename);
  console.log('Commands:', Object.keys(plugin.commands));
  console.log('Functions:', Object.keys(plugin.functions));
  console.log('Autocmds:', Object.keys(plugin.autocmds));

  // Trigger command manually
  await plugin.handleRequest('MyCommand', 'command', [['arg1', 'arg2']]);
}

// Load plugin without caching (for development)
const devPlugin = loadPlugin('./my-plugin.js', nvim, { cache: false });

Complete Plugin Examples

Basic Plugin

import { Plugin, Command, Function as NvimFunction, Autocmd, Neovim } from 'neovim';

@Plugin
export class HelloPlugin {
  private nvim: Neovim;

  constructor(nvim: Neovim) {
    this.nvim = nvim;
  }

  @Command('Hello', { nargs: '0' })
  async hello() {
    await this.nvim.outWriteLine('Hello from plugin!');
  }

  @NvimFunction('AddNumbers')
  async addNumbers(args: number[]) {
    return args.reduce((a, b) => a + b, 0);
  }

  @Autocmd('BufEnter', { pattern: '*.txt' })
  async onTextFile(filename: string) {
    await this.nvim.command('setlocal spell');
  }
}

Advanced Plugin with State

import { Plugin, Command, Function as NvimFunction, Autocmd, Neovim, Buffer } from 'neovim';

interface Note {
  timestamp: number;
  content: string;
}

@Plugin({ dev: true })
export class NotesPlugin {
  private nvim: Neovim;
  private notes: Map<string, Note[]> = new Map();

  constructor(nvim: Neovim) {
    this.nvim = nvim;
  }

  @Command('NoteAdd', { nargs: '*' })
  async addNote(args: string[]) {
    const content = args.join(' ');
    const buf = await this.nvim.buffer;
    const bufName = await buf.name;

    if (!this.notes.has(bufName)) {
      this.notes.set(bufName, []);
    }

    const notes = this.notes.get(bufName)!;
    notes.push({
      timestamp: Date.now(),
      content
    });

    await this.nvim.outWriteLine(`Note added: ${content}`);
  }

  @Command('NoteList', { nargs: '0' })
  async listNotes() {
    const buf = await this.nvim.buffer;
    const bufName = await buf.name;
    const notes = this.notes.get(bufName) || [];

    if (notes.length === 0) {
      await this.nvim.outWriteLine('No notes for this buffer');
      return;
    }

    await this.nvim.outWriteLine(`Notes for ${bufName}:`);
    for (const note of notes) {
      const date = new Date(note.timestamp).toLocaleString();
      await this.nvim.outWriteLine(`  [${date}] ${note.content}`);
    }
  }

  @NvimFunction('NoteCount')
  async noteCount() {
    const buf = await this.nvim.buffer;
    const bufName = await buf.name;
    const notes = this.notes.get(bufName) || [];
    return notes.length;
  }

  @Autocmd('BufDelete', { pattern: '*' })
  async onBufferDelete(filename: string) {
    this.notes.delete(filename);
  }
}

Diagnostic Plugin

import { Plugin, Command, Autocmd, Neovim, Buffer } from 'neovim';

interface Diagnostic {
  line: number;
  col: number;
  message: string;
  severity: 'error' | 'warning' | 'info';
}

@Plugin
export class DiagnosticPlugin {
  private nvim: Neovim;
  private namespace: number | null = null;
  private vtextNamespace: number | null = null;

  constructor(nvim: Neovim) {
    this.nvim = nvim;
  }

  async ensureNamespaces() {
    if (this.namespace === null) {
      this.namespace = await this.nvim.createNamespace('diagnostics');
      this.vtextNamespace = await this.nvim.createNamespace('diagnostics-vtext');
    }
  }

  @Command('DiagnosticsShow', { nargs: '0' })
  async showDiagnostics() {
    await this.ensureNamespaces();

    const buf = await this.nvim.buffer;
    const lines = await buf.lines;

    // Simple linter: find TODO and FIXME
    const diagnostics: Diagnostic[] = [];

    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];

      if (line.includes('TODO')) {
        const col = line.indexOf('TODO');
        diagnostics.push({
          line: i,
          col,
          message: 'TODO found',
          severity: 'info'
        });
      }

      if (line.includes('FIXME')) {
        const col = line.indexOf('FIXME');
        diagnostics.push({
          line: i,
          col,
          message: 'FIXME found',
          severity: 'warning'
        });
      }

      if (line.includes('ERROR')) {
        const col = line.indexOf('ERROR');
        diagnostics.push({
          line: i,
          col,
          message: 'Error marker found',
          severity: 'error'
        });
      }
    }

    // Clear old diagnostics
    await buf.clearNamespace({ nsId: this.namespace! });
    await buf.clearNamespace({ nsId: this.vtextNamespace! });

    // Show diagnostics
    for (const diag of diagnostics) {
      const hlGroup = {
        error: 'ErrorMsg',
        warning: 'WarningMsg',
        info: 'InfoMsg'
      }[diag.severity];

      await buf.addHighlight({
        hlGroup,
        line: diag.line,
        colStart: diag.col,
        colEnd: diag.col + 10,
        srcId: this.namespace!
      });

      await buf.setVirtualText(this.vtextNamespace!, diag.line, [
        [`  ${diag.message}`, hlGroup]
      ]);
    }

    await this.nvim.outWriteLine(`Found ${diagnostics.length} diagnostics`);
  }

  @Command('DiagnosticsClear', { nargs: '0' })
  async clearDiagnostics() {
    await this.ensureNamespaces();

    const buf = await this.nvim.buffer;
    await buf.clearNamespace({ nsId: this.namespace! });
    await buf.clearNamespace({ nsId: this.vtextNamespace! });

    await this.nvim.outWriteLine('Diagnostics cleared');
  }

  @Autocmd('BufWritePost', { pattern: '*' })
  async onSave(filename: string) {
    await this.showDiagnostics();
  }
}

Functional Plugin (Without Decorators)

import { NvimPlugin, Neovim } from 'neovim';

export default function(plugin: NvimPlugin) {
  const nvim = plugin.nvim;

  // Development mode
  plugin.setOptions({ dev: true });

  // Command: Open scratch buffer
  plugin.registerCommand('Scratch', async () => {
    const buf = await nvim.createBuffer(false, true);
    await nvim.command(`buffer ${buf.id}`);
    await buf.setOption('buftype', 'nofile');
    await buf.setOption('bufhidden', 'hide');
    await buf.setLines(['# Scratch Buffer', '', 'Start typing...']);
  }, { nargs: '0' });

  // Function: Get buffer info
  plugin.registerFunction('BufferInfo', async () => {
    const buf = await nvim.buffer;
    const name = await buf.name;
    const lines = await buf.length;
    const modified = await buf.getOption('modified');

    return {
      name,
      lines,
      modified
    };
  });

  // Autocmd: Auto-trim trailing whitespace
  plugin.registerAutocmd('BufWritePre', async (filename) => {
    const buf = await nvim.buffer;
    const lines = await buf.lines;

    const trimmed = lines.map(line => line.replace(/\s+$/, ''));
    await buf.setLines(trimmed);
  }, { pattern: '*.js,*.ts,*.py' });
}

Plugin Installation and Discovery

Installing as Remote Plugin

  1. Install neovim package globally:

    npm install -g neovim
  2. Create plugin directory structure:

    ~/.config/nvim/rplugin/node/
    ├── my-plugin/
    │   ├── package.json
    │   ├── index.js
    │   └── node_modules/
  3. Plugin must export default function:

    export default function(plugin: NvimPlugin) {
      // Register handlers
    }
  4. Update remote plugin manifest:

    :UpdateRemotePlugins
  5. Restart Neovim

Plugin Structure

// index.ts - plugin entry point
import { Plugin, Command, NvimPlugin, Neovim } from 'neovim';

// Using decorators
@Plugin
export class MyPlugin {
  constructor(private nvim: Neovim) {}

  @Command('MyCommand')
  async myCommand() {
    // Implementation
  }
}

// Or using functional style
export default function(plugin: NvimPlugin) {
  plugin.registerCommand('MyCommand', async () => {
    // Implementation
  });
}

Plugin package.json

{
  "name": "my-neovim-plugin",
  "version": "1.0.0",
  "main": "./index.js",
  "engines": {
    "node": ">=10"
  },
  "dependencies": {
    "neovim": "^5.4.0"
  }
}

Important Notes

  • Install neovim package globally for remote plugins: npm install -g neovim
  • Plugins must be in rplugin/node/ directory on Neovim's runtimepath
  • Run :UpdateRemotePlugins after adding/changing plugins
  • Restart Neovim after updating plugin manifest
  • Use dev: true option during development to reload on each invocation
  • Plugin methods are async by default; use sync: true for blocking behavior
  • Autocmds require a pattern option
  • Commands invoked with :CommandName
  • Functions invoked with :call FunctionName(args)
  • Autocmds triggered automatically by Neovim events
  • Use plugin.nvim to access Neovim API in functional plugins
  • Constructor receives Neovim instance in decorator-based plugins
  • The Function export is renamed as NvimFunction to avoid name conflicts
  • For local development, use alwaysInit: true to reinitialize on each call
  • Handlers can be [object, method] tuples for method binding
  • Plugins are discovered via loadPlugin() function by the plugin host