Tree-sitter syntax highlighting, LSP attachment, and LuaSnip snippets for EJS templates in Neovim. ejs.nvim wires up the embedded_template parser with language injection for accurate HTML and JavaScript colorization.

EJS Template Support for Neovim

ejs.nvim is a free, open source Neovim plugin that brings first-class EJS (Embedded JavaScript) template support to Neovim. It is available now on GitHub and dotfyle.

The State of EJS Tooling in Neovim

EJS is widely used as the templating layer in Express.js applications, but Neovim has no built-in filetype definition or Tree-sitter parser for it. Out of the box, .ejs files open as plaintext. Aliasing the filetype to HTML is the most common workaround, but it drops all JavaScript highlighting inside EJS tags. The htmldjango parser is another rough substitute, but it does not understand EJS tag syntax and produces incorrect tokenization at tag boundaries.

ejs.nvim does not ship a custom parser. Instead, it wires up the existing tree-sitter-embedded-template grammar, which is purpose-built for the <% %> tag structure shared by EJS and ERB. Combined with Neovim’s language injection system, this gives .ejs files accurate, parser-backed highlighting for both their HTML and JavaScript content without duplicating work the ecosystem has already done.

Features

Tree-sitter Syntax Highlighting

ejs.nvim registers embedded_template as the Tree-sitter parser for the ejs filetype and ships an injection query file at queries/ejs/injections.scm. The injection query injects html into (content) nodes (the text outside <% %> tags) and javascript into (code) nodes that are children of (directive) or (output_directive) nodes. The result is that HTML receives full HTML highlighting and EJS tag content receives full JavaScript highlighting, both driven by their respective parsers.

The HTML injection uses #set! injection.combined, which merges all (content) fragments into a single virtual HTML document before parsing. Without this, each content fragment is parsed independently. Fragments that begin mid-document, such as those following a closing </script> or </style> tag, cause the HTML parser to enter error recovery and produce only ERROR nodes. With injection.combined the HTML parser sees a coherent document, produces proper element nodes, 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 JavaScript fragment, since concatenating disconnected scriptlet blocks rarely produces valid JavaScript.

LSP Attachment

On FileType ejs, ejs.nvim starts two language servers:

ServerHandles
vscode-html-language-server (html-lsp)HTML content between EJS tags
typescript-language-server (ts_ls)JavaScript content inside EJS tags

Both clients include duplicate-attachment guards that call vim.lsp.get_clients() before starting, so reopening an EJS buffer does not spawn redundant server processes.

ts_ls is attached with an on_attach callback that disables the documentHighlightProvider capability for the client. Without this, Neovim sends textDocument/documentHighlight requests 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 sends the request only when it is, clearing stale highlights when the cursor moves back into HTML content.

Root directory resolution uses vim.fs.root(bufnr, { 'package.json', '.git' }), falling back to vim.fn.getcwd() when no root marker is found.

LuaSnip Snippets

Snippets are registered for the ejs filetype and loaded only if LuaSnip is installed. The snippets module is wrapped in pcalland returns silently if LuaSnip is absent, producing no errors. All snippet triggers use tab stops for jumping between editable fields.

TriggerExpands to
<%=<%= expression %>
<%-<%- expression %>
<%<% code %>
<%#<%# comment %>
ejsinclude<%- include('path/to/partial', { }) %>
ejsforforEach loop scaffold with opening and closing scriptlets
ejsifif / else block scaffold
ejspageFull HTML5 document scaffold with <html><head>, and <body>

Health Checks

:checkhealth ejs verifies the full setup and reports the status of each component:

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

Warnings mean the corresponding feature is unavailable but the rest of the plugin still works. Errors indicate a required component is missing.

Graceful Degradation

All dependencies beyond Neovim itself are optional. A minimal installation with no tree-sitter, no language servers, and no LuaSnip still registers the ejs filetype and provides a foundation for any additional tools the user already has configured. Each feature module checks for its dependency before activating and skips silently if it is absent.

How It Works

Filetype detection is handled by ftdetect/ejs.lua, which calls vim.filetype.add({ extension = { ejs = 'ejs' } }) at startup. This runs before any plugin initialization and ensures the ejs filetype is registered even if the plugin itself is loaded lazily.

lua/ejs/treesitter.lua calls vim.treesitter.language.register('embedded_template', 'ejs'), which tells Neovim’s Tree-sitter integration to use the embedded_template parser for any buffer with filetype ejs. The injection queries in queries/ejs/injections.scm are picked up automatically by Neovim’s query resolution once the parser is registered.

The plugin exposes require('ejs').setup(opts) as its single entry point. With lazy.nvim, passing opts = {} calls setup()automatically.

Installation

lazy.nvim

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

After installation, run :TSInstall embedded_template html css inside Neovim to install the required Tree-sitter parsers. This only needs to be done once.

Other plugin managers are also supported. See the full installation documentation for vim-plug, packer.nvim, mini.deps, and the built-in packages approach.

LSP support requires html-lsp and typescript-language-server on $PATH. Both can be installed via npm:

npm install -g vscode-langservers-extracted typescript typescript-language-server

Source code is available at github.com/ConnorOnTheWeb/ejs.nvim.

RSS Feed Newsletter
Contact us

Latest Blog Posts