ejs.nvim icon

ejs.nvim

Neovim Plugin for EJS Template Support

ejs.nvim is a free, open source Neovim plugin that brings first-class EJS (Embedded JavaScript) template support to Neovim. Rather than reinventing a parser, it wires up the existing tree-sitter-embedded-template grammar with language injection, LSP configuration, template navigation, diagnostics, folding, and snippets. It is at feature parity with EJS Colorizer, the VS Code extension this plugin mirrors.

All dependencies beyond Neovim itself are optional. The plugin degrades gracefully when nvim-treesitter, language servers, a completion engine, or LuaSnip are absent, and requires zero manual configuration beyond installation.

Three of the extension's features are deliberately left out, because in Neovim they belong to other tools: Emmet expansion, Prettier formatting (conform.nvim or formatprg), and the Outline provider (LSP symbols or aerial.nvim). The extension's joined-program JavaScript syntax check is also skipped — ts_ls is already attached to <% %> regions and reports real syntax errors with better positions than that heuristic can.

Features

  • Tree-sitter syntax highlighting

    HTML outside <% %> tags, JavaScript inside them, via Neovim's native Tree-sitter highlighter.

  • Language injection

    Registers embedded_template as the parser for the ejs filetype, and ships queries/embedded_template/injections.scm to inject html into content nodes and javascript into directive/output-directive code nodes.

  • LSP attachment

    html-lsp attaches for the HTML portions and ts_ls attaches for the JavaScript portions, both on FileType ejs, with duplicate-attachment guards.

  • include() path completion and navigation

    Completion inside the quotes of an include() call, with directories offered with a trailing slash and re-triggering so navigating into one keeps completing. Paths are offered without the .ejs extension, the form that actually appears in templates. Plain gf and :EjsDefinition open the target: ftplugin/ejs.lua sets suffixesadd and an includeexpr, so include('partials/head') resolves to views/partials/head.ejs — supplying the missing extension and searching the views root, neither of which gf can do on its own.

  • Include resolution that handles both conventions

    File-relative first, matching EJS's own runtime and EJS Colorizer's includeResolver.ts, then the views root — the nearest ancestor directory named views, else <project root>/views, mirroring how Express Map derives it from app.set('views', ...). A leading / resolves against the views root only. .ejs, .html, and .htm are all tried, and include('partials') finds partials/index.ejs.

  • Diagnostics

    An include() path matching no file is reported, with a message naming the directories that were searched. A <%# %> comment that ends early is also flagged — the case where the commented-out text itself contains a tag, so EJS's scan to the first %> closes the comment there and the remainder leaks back into the template as markup. Include detection is scoped to the Tree-sitter code regions, so include() written in prose, in an HTML comment, or in a <script> body is left alone; without the embedded_template parser it falls back to a text scan for <% %> spans rather than reporting nothing. A commented-out include() raises no warning, but :EjsDefinition and completion still follow it.

  • Hover documentation on K

    Documentation for the delimiter under the cursor. The distinctions worth documenting are the ones not guessable from the syntax: <%= escapes its output and <%- does not, which is the XSS-relevant difference, and %>, -%>, and _%> differ in what whitespace they consume. Openers are matched longest-first so <%# is never read as <% with a stray #. K in an EJS buffer already belongs to html-lsp or ts_ls, so the mapping answers only on a delimiter and hands off everywhere else.

  • Completion for every major engine

    EJS tag delimiters, block scaffolds (ejsif, ejsfor, ejsinclude, ejspage), and include() paths, served to nvim-cmp, blink.cmp (registered automatically via its runtime API on v1.6+), and a complete-function for Neovim 0.12's built-in completion, mini.completion, and coq_nvim — all from one core. This includes the v6 <%% literal escape, which was missing from the snippets entirely, and makes the scaffolds available to anyone using a different snippet engine or none.

  • Folding for control-flow blocks

    Tree-sitter cannot express these: <% if (x) { %> and <% } %> are two independent directive nodes with the HTML between them belonging to neither, so no single node spans the block. Brace depth inside the code regions is what actually delimits it, counted with string, comment, and <%%-escape awareness so <% const s = "}" %> does not unbalance the file. Folds start open and per-line depths are cached per changedtick.

  • Region-aware commentstring

    .ejs had no commentstring at all, so gc reported an empty string rather than commenting anything. ftplugin/ejs.lua now sets <%# %s %>; because Neovim resolves the comment string from the deepest Tree-sitter tree at the cursor, and this plugin already injects html and javascript, gc produces <!-- --> in markup and // inside <% %> with no further work — deliberately region-aware rather than always emitting <%# %>, because that is how Neovim behaves in every other embedded language.

  • LuaSnip snippets

    Snippets for common EJS patterns, registered for the ejs filetype, loaded only if LuaSnip is installed. Now includes the closing delimiters -%>, _%>, and the v6 %%> literal escape, which were missing from the plugin entirely. LuaSnip is genuinely optional: the completion source offers the same tags and scaffolds independently.

  • Health checks and a test suite

    :checkhealth ejs verifies your Neovim version, installed Tree-sitter parsers, and language server availability. 54 tests run with nvim -l tests/run.lua, covering completion contexts, include resolution against a real fixture tree, code-region scoping under both backends, diagnostics, and folding — needing no test framework or plugin dependencies.

Prerequisites

All dependencies are optional. The plugin degrades gracefully when any of them are absent.

Dependency Purpose Required
Neovim >= 0.10vim.fs.root() APIYes
nvim-treesitter/nvim-treesitterParser management; required for CSS highlighting in <style> blocksRecommended
html-lsp (vscode-html-language-server)HTML language serverOptional
typescript-language-serverJavaScript/TypeScript language serverOptional
hrsh7th/nvim-cmpCompletionOptional
saghen/blink.cmpCompletion (v1.6+ registers automatically)Optional
L3MON4D3/LuaSnipSnippet engineOptional

Installation

lazy.nvim

{
  "connorontheweb/ejs.nvim",
  ft = "ejs",
  dependencies = {
    "nvim-treesitter/nvim-treesitter", -- optional, recommended
    "neovim/nvim-lspconfig",           -- optional
    "L3MON4D3/LuaSnip",               -- optional
  },
  opts = {},
}

With opts = {}, lazy.nvim calls setup() for you.

vim-plug

Plug 'connorontheweb/ejs.nvim'
lua require('ejs').setup()

packer.nvim

use 'connorontheweb/ejs.nvim'

mini.deps

MiniDeps.add('connorontheweb/ejs.nvim')
require('ejs').setup()

No plugin manager (built-in packages)

mkdir -p ~/.local/share/nvim/site/pack/plugins/start
git clone https://github.com/connorontheweb/ejs.nvim \
  ~/.local/share/nvim/site/pack/plugins/start/ejs.nvim

Then add require('ejs').setup() to your init.lua (or lua require('ejs').setup() in init.vim).

Post-install: install the Tree-sitter parsers

With nvim-treesitter installed, run inside Neovim:

:TSInstall embedded_template html css

embedded_template parses the EJS structure, html handles content between EJS tags, and css handles CSS inside <style> blocks injected via the HTML parser's own injection queries.

Configuration

All options default as shown. Pass overrides to require('ejs').setup(), or via opts if using lazy.nvim:

require('ejs').setup({
  treesitter  = true,  -- register the embedded_template parser for .ejs files
  lsp         = true,  -- attach html-lsp and ts_ls on FileType ejs
  snippets    = true,  -- load LuaSnip snippets (silently skipped if LuaSnip absent)
  diagnostics = true,  -- unresolved include() paths and early-closing  comments
  folding     = true,  -- fold control-flow blocks (folds start open)
  hover       = true,  -- K documents the delimiter under the cursor

  completion = {
    cmp      = true,   -- register the nvim-cmp source
    blink    = true,   -- register the blink.cmp source (v1.6+)
    omni     = false,  -- complete-function for built-in/mini.completion/coq_nvim
    snippets = true,   -- accepting `<%=` inserts `<%= | %>`
  },
})

snippets now controls LuaSnip only; the completion table controls the completion source, which offers the same tags and scaffolds independently. Each completion adapter is skipped silently when its engine is absent.

Commands

:EjsHealth and :EjsDefinition (open the include() target under the cursor; plain gf works too).

Health Checks

Run :checkhealth ejs to diagnose your setup:

CheckPass conditionFailure level
Neovim version>= 0.10Error
embedded_template parserInstalled via :TSInstallError
html parserInstalled via :TSInstallWarning
css parserInstalled via :TSInstallWarning
vscode-html-language-serverFound on $PATHWarning
typescript-language-serverFound on $PATHWarning
LuaSnipInstalled and loadableWarning

How It Works

queries/embedded_template/injections.scm injects html into (content) nodes (text outside <% %> tags) and javascript into (code) nodes that are children of (directive) or (output_directive) nodes.

The HTML injection uses #set! injection.combined so all (content) fragments are merged into a single virtual HTML document before parsing. This is required for correctness: without it, each fragment is parsed independently, and fragments that start mid-document (after a </script> tag, for example) cause the HTML parser to immediately enter error recovery and produce only ERROR nodes. With injection.combined the HTML parser sees a coherent document, produces proper element nodes including style_element, and its own injection queries fire correctly to inject CSS inside <style> blocks. JavaScript injections do not use injection.combined: each (code) block is parsed as an independent JS fragment, since concatenating disconnected scriptlet blocks rarely produces valid JavaScript.

On the LSP side, ts_ls is attached with an on_attach callback that disables the documentHighlightProvider capability for the client. Without this, Neovim sends textDocument/documentHighlight to ts_ls whenever the cursor rests on an HTML node, which the server cannot handle and returns a -32603 error. A buffer-local CursorHold autocommand replaces the default dispatch: it uses vim.treesitter.get_node() to check whether the cursor is inside a (code) node and only sends the request to ts_ls when it is, clearing stale highlights when the cursor moves back into HTML content.

Related