Lucas Rangel
Toggle menu

Bringing Emmet to Phlex: Building a Language Server for Ruby View Components

January 19, 2025
5 min read
index

My usual Emmet workflow stopped at the boundary between HTML and Phlex. header.site-header could expand to HTML, but I needed header(class: 'site-header') { }, a Ruby method call.

I built phlex-emmet-lsp to translate a practical subset of Emmet syntax into Phlex code and return it to an editor through the Language Server Protocol (LSP).

The first version supports tree structure, IDs, and classes

The parser covers child (>), sibling (+), multiplication (*), IDs, classes, and implicit div tags. It does not support climb-up (^), item numbering ($), grouping with parentheses, or arbitrary bracket attributes. It recognizes {text}, but I have not wired that field into the renderer.

That scope supports expressions such as ul>li*3, header.site-header, and main#content. More complex syntax should fail without replacing source, though the server does not yet explain whether a missing completion means invalid syntax or an integration problem.

Standard Emmet expansion produces the wrong language

Emmet compresses an HTML tree into a short expression. For example:

nav.main-nav>ul>li*3

A standard HTML-aware editor expands it to:

<nav class="main-nav">
<ul>
<li></li>
<li></li>
<li></li>
</ul>
</nav>

A larger navigation component shows the Ruby form:

class NavigationComponent < Phlex::HTML
def template
nav(class: "main-nav") do
ul do
3.times do |i|
li do
a(href: "#") { "Link #{i + 1}" }
end
end
end
end
end
end

Phlex components can use normal Ruby methods, objects, and control flow without switching into a template language. That is the appeal, but it also means an HTML expansion cannot be pasted in as the component body. Supporting Emmet requires a renderer that understands Phlex’s nested method calls.

Parsing, rendering, and LSP form the translation path

The core path has three stages. The parser turns an abbreviation into an abstract syntax tree (AST). The renderer turns each AST node into Ruby source. The LSP layer connects that translation to completion requests from an editor.

Document synchronization and cursor-range calculation sit around that core path. LSP provides a common protocol, but each editor still needs client configuration or an extension.

flowchart TD A[Editor] -->|Completion request| B[LSP server] B --> C[Extract abbreviation at cursor] C --> D[PEG parser] D --> E[Emmet AST] E --> F[Phlex renderer] F --> G[Completion text edit] G --> A

I chose Rust because it can ship as a native executable. peg generates the parser, async-lsp handles protocol messages, and ropey stores synchronized document text.

A Ruby implementation would have reduced the language switch while working on a Ruby tool. It would also have required a Ruby runtime wherever the server runs. Rust moves that cost into compilation and ownership rules, then produces a server process with no Ruby runtime dependency after installation. Users who install through Cargo still need Rust tooling for that installation step.

Ordered PEG rules map Emmet syntax to AST fields

A Parsing Expression Grammar (PEG) describes a language with ordered choices. When several alternatives could match, the parser tries them in grammar order. That deterministic choice suits compact syntax in which #, ., *, +, and > each change the node being built. Order is also the trade-off: adding a rule can change which existing alternative wins, so grammar changes require checking overlaps rather than appending another case blindly.

Here is a simplified grammar sketch for the supported concepts:

peg::parser! {
grammar emmet_parser() for str {
pub rule parse() -> Vec<EmmetNode>
= siblings()
rule siblings() -> Vec<EmmetNode>
= first:node() rest:("+" n:node() { n })* {
let mut nodes = vec![first];
nodes.extend(rest);
nodes
}
rule node() -> EmmetNode
= ident:identifier()?
id:("#" id:identifier() { id })?
classes:("." class:identifier() { class })*
mul:("*" n:number() { n })?
text:("{" t:text() "}" { t })?
children:(">" c:siblings() { c })? {
EmmetNode {
tag: ident.unwrap_or_else(|| "div".to_string()),
id,
classes,
multiplier: mul.unwrap_or(1),
text,
children: children.unwrap_or_default(),
}
}
}
}

This is an explanatory sketch, not a copy of the repository’s current parser. It shows the important boundary: parsing produces structured nodes rather than Ruby strings. A node carries its tag, ID, classes, multiplier, text, children, and siblings. An omitted tag defaults to div, so .card can become a div with a class.

The AST prevents protocol concerns from leaking into the grammar. The parser does not know about cursor positions or completion items, and the LSP layer does not need to know how > differs from +.

Rendering is where Emmet semantics meet Ruby syntax

The renderer recursively maps nodes to Phlex method calls. IDs and classes become keyword arguments, multiplication repeats a rendered node, children become nested calls, and siblings follow the current node.

This stage carries more risk than its string-building code suggests. Generated text must be valid Ruby, attribute values need escaping, and indentation must remain readable after recursive expansion. A parser can accept an abbreviation correctly while the renderer still emits unusable code.

I left one such gap in this version: the parser stores {text} content, but the renderer does not emit that field. Text therefore is not supported output even though the grammar recognizes it. Bracket attributes such as [href="#"] are also absent from the parser.

Those limits are why I keep parsing and rendering as separate steps. A grammar test can prove that a node was recognized, while a renderer test can compare the generated Ruby without starting an editor or LSP client.

Completion requests replace only the abbreviation

When the editor requests completion, the server reads the synchronized document, finds the abbreviation immediately before the cursor, and attempts to parse it. A successful parse becomes a CompletionItem containing a text edit for that abbreviation’s range.

sequenceDiagram participant User participant Editor participant Server as LSP Server participant Parser participant Renderer User->>Editor: Types "div.container>p" Editor->>Server: textDocument/completion alt Document is synchronized Server->>Parser: Parse abbreviation alt Parse succeeds Parser->>Server: AST Server->>Renderer: Render AST Renderer->>Server: Phlex Ruby code Server->>Editor: CompletionItem with text edit else Parse fails Server->>Editor: No completion end else Document is missing Server->>Editor: Request fails end

If parsing fails, the server returns no completion. That avoids replacing source with a partial expansion, but it also makes unsupported syntax look the same as an inactive server. Diagnostics or editor-visible logging would make that failure easier to distinguish.

I also left a document-lifecycle bug. The server records text on textDocument/didChange, while its textDocument/didOpen handler does not populate the document map. A completion request before the first change can therefore fail to find the document. I need to fix that path before calling the integration reliable across LSP clients.

I will add syntax only alongside its parser and renderer cases. For $, that means carrying an item index through multiplication. For grouping and ^, it means representing changes in tree position rather than treating every operator as another property on one node.

Install the server, then add an editor client

Install the server with Cargo:

Terminal window
cargo install phlex_emmet_ls

Editor support remains client-specific. The repository documents a separate phlex-emmet.nvim plugin for Neovim and a phlex-emmet-ls extension for VS Code.

Source code, editor setup, and the feature checklist live in the phlex-emmet-lsp repository. I plan to close the text-rendering and document-open gaps before expanding the grammar, so the existing subset behaves consistently first.