Lucas Rangel

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

January 19, 2025
6 min read
index

The first time I discovered Emmet, it felt like unlocking a cheat code for HTML. Writing ul>li*5 and watching it expand into a complete unordered list was pure magic. Years later, when I started using Phlex for Ruby view components, I found myself missing that familiar shorthand. This is the story of how I bridged that gap.

The Power of Emmet

Emmet has become an indispensable tool in modern web development. Its abbreviation syntax transforms the way we write markup, turning verbose HTML structures into concise, expressive shortcuts. Consider this simple example:

nav.main-nav>ul>li*3>a[href="#"]{Link $}

In traditional HTML editors, this expands to:

<nav class="main-nav">
  <ul>
    <li><a href="#">Link 1</a></li>
    <li><a href="#">Link 2</a></li>
    <li><a href="#">Link 3</a></li>
  </ul>
</nav>

The productivity gain is immediate and substantial. What would take multiple lines and careful attention to closing tags becomes a single line of intuitive abbreviations.

Enter Phlex: Ruby’s Answer to Component-Based Views

Phlex represents a paradigm shift in Ruby view development. Rather than mixing HTML with Ruby through ERB templates, Phlex treats views as pure Ruby objects. Each HTML element becomes a method call, creating a component-based architecture that feels remarkably similar to JSX.

Here’s a basic Phlex component:

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

Compare this with its JSX equivalent:

function NavigationComponent() {
  return (
    <nav className="main-nav">
      <ul>
        {[1, 2, 3].map(i => (
          <li key={i}>
            <a href="#">Link {i}</a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Both approaches share a fundamental philosophy: components are code, not templates. They leverage the full power of their respective languages while maintaining a clear, hierarchical structure. The key difference lies in syntax: JSX maintains HTML-like syntax within JavaScript, while Phlex embraces Ruby’s block syntax for nesting.

The Missing Piece: Emmet in Phlex

While Phlex’s Ruby-centric approach offers numerous advantages—type safety, testability, and full language features—it comes with a trade-off. Writing div(class: "container") { } is more verbose than <div class="container">. For developers accustomed to Emmet’s efficiency, this verbosity can feel like a step backward.

The challenge became clear: How could we bring Emmet’s abbreviation power to Phlex’s Ruby-based templates? The answer lay in building a Language Server Protocol (LSP) implementation that could understand Emmet syntax and translate it to Phlex Ruby code in real-time.

Architectural Overview

The solution required bridging three distinct technologies: Emmet’s abbreviation syntax, Phlex’s Ruby DSL, and the Language Server Protocol for editor integration. Here’s the high-level architecture:

I chose Rust for the implementation for several reasons:

  1. Performance: LSP servers need to respond quickly to provide real-time completions
  2. Memory Safety: Rust’s ownership model prevents common bugs in long-running processes
  3. Ecosystem: Excellent libraries for parsing (peg) and LSP implementation (async-lsp)
  4. Binary Distribution: Easy to package as a standalone executable for any platform

Implementation Deep Dive

Understanding Parsing Expression Grammars

Before diving into the parser implementation, it’s worth understanding the parsing approach. The project uses a Parsing Expression Grammar (PEG), a type of formal grammar that describes a formal language in terms of a set of rules for recognizing strings in the language.

PEGs differ from traditional Context-Free Grammars (CFGs) in that they are unambiguous by design—each parsing expression matches at most one string. They use ordered choice (denoted by /) instead of unordered alternation, making them deterministic and well-suited for parsing programming languages and DSLs like Emmet.

For Rust developers, the peg crate provides a macro-based approach to defining PEGs directly in Rust code. It generates a recursive descent parser at compile time, offering excellent performance and type safety. The syntax is intuitive: you define rules using pattern matching syntax, and the macro generates the corresponding parser code.

Parsing Emmet Abbreviations

The parser transforms Emmet abbreviations into an Abstract Syntax Tree (AST). Here’s a simplified version of the grammar:

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 grammar handles the core Emmet features: elements, IDs, classes, multiplication, text content, and nesting.

Transforming to Phlex

The renderer takes the AST and generates Phlex Ruby code. The transformation handles several key aspects:

fn render_node(node: &EmmetNode, indent: usize) -> String {
    let mut result = String::new();
    let indent_str = "  ".repeat(indent);
    
    // Generate method call with attributes
    result.push_str(&format!("{}{}", indent_str, node.tag));
    
    let mut attrs = vec![];
    if let Some(id) = &node.id {
        attrs.push(format!("id: '{}'", id));
    }
    if !node.classes.is_empty() {
        attrs.push(format!("class: '{}'", node.classes.join(" ")));
    }
    
    if !attrs.is_empty() {
        result.push_str(&format!("({})", attrs.join(", ")));
    }
    
    // Handle content and children
    if node.children.is_empty() && node.text.is_none() {
        result.push_str(" { }");
    } else {
        result.push_str(" { ");
        if let Some(text) = &node.text {
            result.push_str(text);
        }
        // Recursively render children...
    }
    
    result
}

LSP Integration

The LSP server monitors every keystroke, extracting potential Emmet abbreviations and offering completions:

The server maintains document state using the ropey crate for efficient text manipulation:

async fn completion(&self, params: CompletionParams) -> Result<CompletionResponse> {
    let doc = self.get_document(&params.text_document.uri)?;
    let position = params.position;
    
    // Extract abbreviation from cursor position
    let line = doc.line(position.line as usize);
    let abbr = extract_abbreviation(&line, position.character as usize);
    
    // Parse and render
    if let Ok(nodes) = emmet_parser::parse(&abbr) {
        let phlex_code = render_nodes(&nodes);
        
        return Ok(CompletionResponse::Array(vec![
            CompletionItem {
                label: abbr.clone(),
                kind: Some(CompletionItemKind::SNIPPET),
                detail: Some("Emmet abbreviation".to_string()),
                insert_text: Some(phlex_code),
                insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
                ..Default::default()
            }
        ]));
    }
    
    Ok(CompletionResponse::Array(vec![]))
}

Usage and Results

The transformation is seamless. Here are some before/after examples:

EmmetPhlex Output
header.site-headerheader(class: 'site-header') { }
main#contentmain(id: 'content') { }
ul>li*3
ul {
li { }
li { }
li { }
}
div.card>h2{Title}+p{Description}
div(class: 'card') {
h2 { Title }
p { Description }
}

The editor integration is straightforward. For Neovim users:

require('lspconfig').phlex_emmet_lsp.setup({
  filetypes = { 'ruby' },
  init_options = { file_extensions = { 'rb' } }
})

For VS Code, install the phlex-emmet-ls extension from the marketplace.

Lessons Learned and Future Directions

Building this Language Server taught me several valuable lessons:

  1. Parsing is half the battle: A robust parser that handles edge cases gracefully is crucial for a good user experience
  2. Real-time performance matters: Even small delays in completion suggestions break the flow
  3. Less is more: Supporting core Emmet features well is better than incomplete support for everything

Future enhancements on the roadmap include:

  • Support for climb-up operator (^) for complex nested structures
  • Item numbering with $ for dynamic content
  • Grouping with parentheses for more complex abbreviations
  • Integration with more editors and development environments

The intersection of different programming paradigms—Emmet’s DSL, Phlex’s Ruby approach, and LSP’s protocol—demonstrates how we can build bridges between tools to enhance developer experience. Sometimes the best solution isn’t choosing one tool over another, but finding ways to make them work together.

The phlex-emmet-lsp project is open source and available on GitHub. Whether you’re a Phlex user missing Emmet or simply interested in LSP development, I hope this exploration provides insight into building developer tools that make our daily work more enjoyable and productive.