How to get syntax highlighting, LSP completion (otter), and formatting for
JavaScript/CSS written inside .ex files — i.e. Phoenix LiveView colocated
hooks (Phoenix.LiveView.ColocatedHook / ColocatedJS, v1.1+) and
colocated CSS (ColocatedCSS, v1.2+), where JS/CSS lives in <script> /
<style> tags inside a ~H HEEx sigil:
def hook(assigns) do
~H"""
<div id="el" phx-hook=".Sha256">…</div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".Sha256">
export default { mounted() { this.el.innerHTML = sha256(this.el.innerHTML) } }
</script>
<style :type={MyAppWeb.ColocatedCSS}>
.btn { color: var(--color-primary); }
</style>
"""
endEnvironment: Neovim 0.12, nvim-treesitter main, blink.cmp, conform.nvim,
otter.nvim, mason. (The author's dotfiles use chezmoi, but nothing here requires
it — the parser install step below offers a manual build, a portable script, and
an nvim-treesitter option.)
tree-sitter-heex parses the body of <script>/<style> as generic HEEx
nodes: every { opens an interpolation expression (parsed as Elixir), every
< starts a tag, and the rest fragments into whitespace-split text nodes. So
the JS/CSS gets shredded — there is no single, contiguous node for language
injection to attach to. This is the blocker: it kills highlighting, otter (it
discovers regions from the treesitter tree), and any treesitter-based formatting.
There is no after/queries fix, because the grammar never produces an injectable
region in the first place. The fix has to be in the grammar.
The working stack is three layers deep — elixir → heex (~H sigil) → js/css —
so the parser has to expose the innermost region cleanly.
Fork: barnabasJ/tree-sitter-heex, branch feat/raw-text-script-style.
Two changes to grammar.js plus a small external scanner.
// 1. Declare an external token for the opaque script/style body, plus an
// error_sentinel (only ever "valid" during error recovery — lets the
// scanner bail so it doesn't reshape error trees).
externals: ($) => [$.raw_text, $.error_sentinel],
// 2. A <script>/<style> tag is a start tag, an opaque raw_text body, end tag.
// `_raw_start_tag` is aliased to `start_tag` so the node shape stays
// identical to every other tag (existing highlight queries keep working).
tag: ($) =>
choice(
seq($.start_tag, repeat($._node), $.end_tag),
seq(alias($._raw_start_tag, $.start_tag), optional($.raw_text), $.end_tag),
$.self_closing_tag
),
_raw_start_tag: ($) =>
seq(
"<",
alias($._raw_tag_name, $.tag_name),
repeat(choice($.attribute, $.expression, $.special_attribute)),
">"
),
// Higher token precedence makes the lexer prefer this over the generic
// tag_name regex for exactly "script"/"style" (so <scripts> stays a normal tag).
_raw_tag_name: ($) => token(prec(1, choice("script", "style"))),
// 3. BONUS FIX: colocated tags always carry :type={...}. The grammar's special
// attribute list was hardcoded (:let/:for/:key/:stream/:if) and could not
// parse :type at all. Generalise it to any :-prefixed name.
special_attribute_name: ($) => token(seq(":", /[a-zA-Z][a-zA-Z0-9_-]*/)),Scans from just after the start tag's > until the matching </script/</style
(case-insensitive, browser-faithful — a bare < or a </div> string inside the
JS does not end it), emitting it all as one raw_text token.
#include "tree_sitter/parser.h"
#include <stdbool.h>
enum TokenType { RAW_TEXT, ERROR_SENTINEL };
void *tree_sitter_heex_external_scanner_create(void) { return NULL; }
void tree_sitter_heex_external_scanner_destroy(void *p) {}
unsigned tree_sitter_heex_external_scanner_serialize(void *p, char *b) { return 0; }
void tree_sitter_heex_external_scanner_deserialize(void *p, const char *b, unsigned n) {}
static inline bool is_lower_alpha(int32_t c) { return c >= 'a' && c <= 'z'; }
static inline int32_t to_lower(int32_t c) { return (c >= 'A' && c <= 'Z') ? c + 32 : c; }
static inline bool is_name_char(int32_t c) {
return is_lower_alpha(to_lower(c)) || (c >= '0' && c <= '9') || c == '-' || c == '_';
}
bool tree_sitter_heex_external_scanner_scan(void *payload, TSLexer *lexer,
const bool *valid_symbols) {
if (valid_symbols[ERROR_SENTINEL]) return false; // in error recovery: bail
if (!valid_symbols[RAW_TEXT]) return false;
bool advanced = false;
while (lexer->lookahead != 0) {
if (lexer->lookahead != '<') {
lexer->advance(lexer, false); lexer->mark_end(lexer); advanced = true; continue;
}
lexer->advance(lexer, false); // consume '<' (boundary still before it)
if (lexer->lookahead != '/') { lexer->mark_end(lexer); advanced = true; continue; }
lexer->advance(lexer, false); // consume '/'
char name[7]; int n = 0;
while (n < 6 && is_lower_alpha(to_lower(lexer->lookahead))) {
name[n++] = (char)to_lower(lexer->lookahead); lexer->advance(lexer, false);
}
name[n] = '\0';
bool is_close =
((n == 6 && name[0]=='s'&&name[1]=='c'&&name[2]=='r'&&name[3]=='i'&&name[4]=='p'&&name[5]=='t') ||
(n == 5 && name[0]=='s'&&name[1]=='t'&&name[2]=='y'&&name[3]=='l'&&name[4]=='e'));
if (is_close && is_name_char(lexer->lookahead)) is_close = false; // </scripts> isn't a close
if (is_close) { lexer->result_symbol = RAW_TEXT; return advanced; } // leave </script> for end_tag
lexer->mark_end(lexer); advanced = true; // '<' '/' letters were content
}
lexer->mark_end(lexer); lexer->result_symbol = RAW_TEXT; return advanced; // EOF, malformed
}Regenerate + test: tree-sitter generate && tree-sitter test — all corpus tests
pass (the _raw_start_tag → start_tag alias keeps existing trees unchanged; the
error_sentinel keeps error-recovery trees unchanged). Commit the generated
src/parser.c + src/scanner.c so consumers can build with just cc.
Neovim loads the parser from an explicit path (language.add(..., { path })
in Part 2a), so all you need is a heex.so at a known location. This guide uses
~/.local/share/nvim/heex-fork/parser/heex.so ($XDG_DATA_HOME/nvim/...) — use
any path you like, just keep it in sync with the heex_so path in 2a. The fork
commits the generated src/parser.c, so you only need a C compiler (cc),
not the tree-sitter CLI. Pick whichever install method fits your setup; none of
them assume a particular dotfile manager.
Option 1 — one-time manual build (works anywhere).
git clone --depth 1 --branch feat/raw-text-script-style \
https://github.com/barnabasJ/tree-sitter-heex /tmp/ts-heex
mkdir -p ~/.local/share/nvim/heex-fork/parser
# Linux:
cc -shared -fPIC -Os -I /tmp/ts-heex/src \
/tmp/ts-heex/src/parser.c /tmp/ts-heex/src/scanner.c \
-o ~/.local/share/nvim/heex-fork/parser/heex.so
# macOS: swap `-shared` for `-bundle -undefined dynamic_lookup`(If you fork further and edit grammar.js, run tree-sitter generate first to
regenerate src/parser.c.)
Option 2 — a portable install/update script (idempotent, SHA-pinned,
degrades gracefully if offline or no compiler). Run it once, on a schedule, or
on every update — it no-ops when already built at the pinned SHA. Drop it into
whatever you use: a lazy.nvim plugin build step, a Makefile, an Ansible
task, a chezmoi run_onchange_*.sh, or just bash install-heex-parser.sh.
#!/usr/bin/env bash
set -euo pipefail
SHA="<pinned-commit>"; BRANCH="feat/raw-text-script-style"
REPO="https://github.com/barnabasJ/tree-sitter-heex.git"
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/nvim/heex-fork"
OUT="$DATA/parser/heex.so"; STAMP="$DATA/.built-sha"
command -v cc >/dev/null || { echo "no C compiler; using stock heex parser" >&2; exit 0; }
command -v git >/dev/null || { echo "no git" >&2; exit 0; }
[ -f "$OUT" ] && [ "$(cat "$STAMP" 2>/dev/null||true)" = "$SHA" ] && exit 0
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
build() {
git clone -q --depth 1 --branch "$BRANCH" "$REPO" "$TMP/src" || return 1
mkdir -p "$DATA/parser"
case "$(uname -s)" in Darwin) f=(-bundle -undefined dynamic_lookup);; *) f=(-shared);; esac
cc "${f[@]}" -fPIC -Os -I "$TMP/src/src" "$TMP/src/src/parser.c" "$TMP/src/src/scanner.c" -o "$OUT" || return 1
}
if build; then echo "$SHA" >"$STAMP"; echo "built -> $OUT"; else echo "build failed; keeping stock parser" >&2; fiOption 3 — let nvim-treesitter build it (only on the master branch). If
you're still on nvim-treesitter master (not main), register the fork as the
heex parser source and :TSInstall heex:
require("nvim-treesitter.parsers").get_parser_configs().heex.install_info = {
url = "https://github.com/barnabasJ/tree-sitter-heex",
branch = "feat/raw-text-script-style",
files = { "src/parser.c", "src/scanner.c" },
}
-- :TSInstall heex (or :TSUpdate heex)With this option nvim-treesitter installs to its own parser dir and wins the
registration itself, so you can drop the explicit language.add line in 2a
and keep only the query.set part. (On main, nvim-treesitter has no equivalent
install_info override, which is why Options 1–2 build to an explicit path and
2a force-loads it.)
The first language.add for a language wins, so register ours before
nvim-treesitter touches heex (at startup, before any FileType handler). Then set
the injections query at runtime — not as an after/queries file, because a
static query referencing raw_text hard-errors when the stock parser is active.
Guarded: if the parser isn't built, keep the stock parser + stock injections.
local heex_so = vim.fn.stdpath("data") .. "/heex-fork/parser/heex.so"
if (vim.uv or vim.loop).fs_stat(heex_so)
and pcall(vim.treesitter.language.add, "heex", { path = heex_so }) then
-- query.set REPLACES the injections query, so inline nvim-treesitter's heex
-- base verbatim, then add the script/style rules over our raw_text node.
vim.treesitter.query.set("heex", "injections", [==[
(directive
[ (partial_expression_value) (ending_expression_value) ] @injection.content
(#set! injection.language "elixir")
(#set! injection.include-children)
(#set! injection.combined))
((directive (expression_value) @injection.content) (#set! injection.language "elixir"))
(expression (expression_value) @injection.content (#set! injection.language "elixir"))
((comment) @injection.content (#set! injection.language "comment"))
((tag (start_tag (tag_name) @_tag) (raw_text) @injection.content)
(#eq? @_tag "script") (#set! injection.language "javascript"))
((tag (start_tag (tag_name) @_tag) (raw_text) @injection.content)
(#eq? @_tag "style") (#set! injection.language "css"))
]==])
endThat alone gives syntax highlighting for colocated JS/CSS (verified through
the full elixir → heex → javascript/css chain).
otter creates a hidden per-language buffer from each injected region and proxies
LSP to the real servers; completion surfaces through blink's normal lsp source.
return {
"jmbuhr/otter.nvim",
dependencies = { "nvim-treesitter/nvim-treesitter" },
ft = { "elixir", "heex", "eelixir" },
opts = {
lsp = { diagnostic_update_events = { "BufWritePost" } },
buffers = { set_filetype = true, write_to_disk = false },
handle_leading_whitespace = true,
},
config = function(_, opts)
require("otter").setup(opts)
-- *** Critical bug fix ***
-- otter-ls forwards to the hidden buffer with vim.lsp.buf_request, whose
-- callback fires once PER client and replies on the FIRST to answer. With
-- cssls + css_variables both attached, the one returning nothing at the
-- cursor (e.g. css_variables on a plain property) answers first and shadows
-- the other → ZERO completions. Upstream is still first-wins. Wrap
-- buf_request to AGGREGATE: query all completion clients on the otter
-- buffer and reply once with merged items. (Use client:request, not
-- buf_request_all — that's built on buf_request and would recurse.)
if not vim.g.__otter_completion_aggregation then
vim.g.__otter_completion_aggregation = true
local ms = vim.lsp.protocol.Methods
local orig = vim.lsp.buf_request
local function is_otter_buf(b)
local ok, keeper = pcall(require, "otter.keeper"); if not ok then return false end
for _, raft in pairs(keeper.rafts or {}) do
for _, n in pairs(raft.buffers or {}) do if n == b then return true end end
end
return false
end
vim.lsp.buf_request = function(bufnr, method, params, handler)
if method ~= ms.textDocument_completion or not is_otter_buf(bufnr) then
return orig(bufnr, method, params, handler)
end
local clients = vim.lsp.get_clients({ bufnr = bufnr, method = method })
local items, remaining, replied = {}, #clients, false
local function reply()
if not replied then replied = true
handler(nil, { isIncomplete = true, items = items },
{ bufnr = bufnr, method = method, params = params }) end
end
if remaining == 0 then reply(); return {} end
local function on_one(result)
if result then for _, it in ipairs(result.items or result) do items[#items+1] = it end end
remaining = remaining - 1; if remaining == 0 then reply() end
end
for _, c in ipairs(clients) do
local ok = c:request(method, params, function(_, r) on_one(r) end, bufnr)
if not ok then on_one(nil) end
end
return {}
end
end
-- Activate on each elixir/heex buffer (otter is graceful when there's none).
local grp = vim.api.nvim_create_augroup("otter-elixir-heex", { clear = true })
vim.api.nvim_create_autocmd("FileType", {
group = grp, pattern = { "elixir", "heex", "eelixir" },
callback = function() pcall(require("otter").activate, { "javascript", "css" }, true, true) end,
})
end,
}ts_ls = {}, -- JS (hooks). Real .js/.ts files + otter buffers.
cssls = {}, -- general CSS (properties, values, at-rules, same-file vars)
css_variables = { -- cross-file var(--…); indexes the WORKSPACE itself,
settings = { -- so it works inside the hidden otter buffer too.
cssVariables = {
lookupFiles = { "**/*.css", "**/*.scss", "**/*.less", "assets/**/*.css" },
},
},
},You need both cssls (general completion) and css_variables (cross-file
variables) — that's exactly why the aggregation fix in 2b is required.
There is no treesitter/LSP path to format the embedded content — but
mix format is a compile-time pass and sidesteps that entirely. LiveView
1.2.0-rc.0+ ships a Phoenix.LiveView.HTMLFormatter.TagFormatter behaviour that
hands <script>/<style> bodies to prettier. So just run the project's
mix format over stdin:
formatters = {
mix_format = {
command = "mix",
args = function(_, ctx) return { "format", "--stdin-filename", ctx.filename, "-" } end,
stdin = true,
cwd = function(_, ctx) return vim.fs.root(ctx.dirname, { "mix.exs", ".formatter.exs" }) end,
require_cwd = true, -- outside a mix project, fall back to LSP formatting
},
},
formatters_by_ft = { elixir = { "mix_format" }, heex = { "mix_format" } },
-- mix cold-starts the BEAM (~1–2s); bump format-on-save timeout for elixir/heex
-- to ~3000ms or it silently times out at the default 500ms.providers = {
lsp = {
score_offset = 10, -- LSP above path/snippets/buffer
transform_items = function(_, items) -- boost embedded css/js (otter-ls)
for _, item in ipairs(items) do -- above Elixir's `expert` inside ~H
if type(item.client_name) == "string" and item.client_name:match("^otter%-ls") then
item.score_offset = (item.score_offset or 0) + 5
end
end
return items
end,
},
},These can't live in dotfiles. Needs LiveView ~> 1.2.0-rc and npx prettier.
defmodule PrettierFormatter do
@moduledoc false
@behaviour Phoenix.LiveView.HTMLFormatter.TagFormatter
require Logger
@impl true
def render_tag({tag, _attrs, content}, _opts) when tag in ["script", "style"] do
ext = if tag == "style", do: "css", else: "js"
tmp = Path.join(System.tmp_dir!(), "phx_prettier_#{System.unique_integer([:positive])}.#{ext}")
try do
File.write!(tmp, content)
case System.cmd("npx", ["prettier", tmp], stderr_to_stdout: true) do
{out, 0} -> {:ok, String.trim(out)}
{err, _} -> Logger.error("prettier failed: #{err}"); :skip
end
after
File.rm(tmp)
end
end
def render_tag(_tag, _opts), do: :skip
end
[
plugins: [Phoenix.LiveView.HTMLFormatter],
tag_formatters: %{script: PrettierFormatter, style: PrettierFormatter},
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]The hidden otter buffer is anchored next to the .ex in lib/, but JS deps live
in assets/node_modules. Point TS at them:
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "*": ["assets/node_modules/*"] }
}
}Works: highlighting; CSS completion (general + cross-file var(--…)); JS
completion; go-to-def within the block; formatting of colocated JS/CSS via
mix format.
Out of reach (inherent to the hidden-buffer model):
- Cross-file links to your other JS modules, and inbound references — but colocated hooks don't need these.
- Bare package imports need the
jsconfig.jsonshim above (Phoenix'slib/vsassets/node_modulessplit). csslsvscss_variablescan't be split in blink (both arrive merged underotter-ls) — but fuzzy matching surfaces the right one by context.
- Highlighting: open a
.exwith a colocated<script>/<style>; JS/CSS should be colored. - Completion: in
<style>type a property (colo) andvar(--; in<script>type JS. - Formatting:
<leader>f/ save reflows the embedded JS/CSS. - Sanity:
:checkhealth otter, and:lua =vim.tbl_map(function(c) return c.name end, vim.lsp.get_clients())with the cursor in a<style>block should listcssls,css_variables,otter-ls.