Skip to content

Comments

Introduce template formatter#573

Draft
mwmiller wants to merge 1 commit intobartblast:devfrom
mwmiller:template_formatter
Draft

Introduce template formatter#573
mwmiller wants to merge 1 commit intobartblast:devfrom
mwmiller:template_formatter

Conversation

@mwmiller
Copy link
Contributor

@mwmiller mwmiller commented Jan 17, 2026

This is a second pass at introducing a template formatter for HOLO sigils (and also .holo files.) This version uses Inspect Algebra for better overall results.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added formatter plugin for Hologram templates, enabling mix format support for .holo files and ~HOLO sigils with proper handling of inline/block elements, attributes, whitespace normalization, and nested structures.
  • Bug Fixes

    • Improved raw block parsing to properly buffer text before raw block entry.

✏️ Tip: You can customize this high-level summary in your review settings.

@mwmiller mwmiller marked this pull request as draft January 17, 2026 08:28
@coderabbitai
Copy link

coderabbitai bot commented Jan 17, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

This change introduces a template formatter plugin for Hologram, enabling formatting of ~HOLO sigil templates and .holo files. The implementation includes a formatter module implementing the Mix.Tasks.Format behavior, an algebra-based document formatter with comprehensive template handling, parser updates for proper text tag emission, configuration registration, and extensive test coverage.

Changes

Cohort / File(s) Summary
Configuration & Registration
.formatter.exs
Registers Hologram.Template.Formatter as a plugin in the export configuration to enable Mix.Format integration.
Core Formatter Implementation
lib/hologram/template/formatter.ex
Implements Mix.Tasks.Format behavior; exposes formatter for ~HOLO sigils and .holo files; delegates parsing to Parser and formatting to Algebra; handles line-length configuration and whitespace preservation.
Template Algebra Formatter
lib/hologram/template/algebra.ex
Provides comprehensive Inspect.Algebra-based document formatter for template tokens; handles inline/block layout, indentation, attributes, expressions, raw blocks, comments, and whitespace normalization across 570 lines of logic.
Parser Integration
lib/hologram/template/parser.ex
Adds call to maybe_add_text_tag() before raw block processing to ensure buffered text is emitted prior to entering raw block.
Test Coverage
test/elixir/hologram/template/formatter_test.exs
Introduces comprehensive test suite covering inline/block elements, attributes, whitespace handling, nested structures, raw blocks, inviolable tags, and error cases.

Sequence Diagram(s)

sequenceDiagram
    participant Mix as Mix.Format
    participant Fmt as Formatter
    participant Parser
    participant Algebra
    participant Docs as Inspect.Algebra

    Mix->>Fmt: format(content, options)
    Fmt->>Fmt: Extract whitespace & content
    Fmt->>Parser: parse_markup(content)
    Parser-->>Fmt: tokens
    Fmt->>Algebra: format_tokens(tokens)
    Algebra->>Algebra: Parse & transform nodes
    Algebra->>Algebra: Build doc structure
    Algebra-->>Fmt: doc
    Fmt->>Docs: format(doc, line_length)
    Docs-->>Fmt: iodata
    Fmt->>Fmt: Convert to binary & preserve newline
    Fmt-->>Mix: formatted content
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A formatter hops into view,
With tokens old, now formatted new!
Through algebra and parser's dance,
Templates get their format chance—
Clean indents and spacing bright! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Introduce template formatter' accurately summarizes the main purpose of the pull request, which introduces a new template formatter for HOLO sigils and .holo files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@mwmiller
Copy link
Contributor Author

This is marked as a draft until more niggles are worked out. Upon request, I wanted to get this out publicly to make status more clear (and encourage myself to get back to work on it.)

As of now, applying the formatter in the repository itself changes 17 files. Some of these changes I find agreeable; other changes vary from "not so much" to "breaking."

I have also used some AI assistance to get the output to this point. I know that it contains non-idiomatic Elixir in places and I will be performing my own code review. I hope to get it into a state where I can use the tests to avoid regressions in doing so.

I have also applied this to my own Hologram projects and found its results useful. I think the average "in the wild" Hologram project will have less edges to navigate, so getting the output right here will be a good enough win for mainline inclusion.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/hologram/template/parser.ex (1)

646-652: Reset the token buffer when entering a raw block.
Line 648 flushes buffered text but doesn’t clear it, so pre-raw text can be re-emitted inside the raw block at Line 657.

🐛 Proposed fix
 def parse_tokens(%{raw?: false} = context, :text, [{:symbol, "{%raw}"} = token | rest]) do
   context
   |> maybe_add_text_tag()
+  |> reset_token_buffer()
   |> add_processed_token(token)
   |> add_processed_tag({:block_start, "raw"})
   |> enable_raw_mode()
   |> parse_tokens(:text, rest)
 end
🤖 Fix all issues with AI agents
In `@lib/hologram/template/algebra.ex`:
- Around line 446-469: from_attr_value currently treats parts that are entirely
empty/whitespace as absent and returns empty(), which drops explicit
empty-string attributes like id=""; change the logic so that when relevant_parts
== [] you first check the original parts for an explicit {:text, ""} (or any
text exactly equal to ""), and if present return concat(string("="),
string("\"\"")) (use the existing concat/string helpers) instead of empty();
keep the existing flow (using tighten_expression, hd(relevant_parts), etc.) for
other cases so only truly omitted attributes remain boolean while explicit empty
strings are preserved.
- Around line 82-129: The recursive consumers do_consume_until_end_tag/4 and
do_consume_until_end_block/4 lack base clauses for an empty token list, causing
a FunctionClauseError on unbalanced markup; add explicit [] clauses to both
functions to handle unclosed items (e.g. defp do_consume_until_end_tag([],
tag_to_match, _acc, _level) and defp do_consume_until_end_block([],
tag_to_match, _acc, _level)) and return a clear error (for example {:error,
{:unclosed_tag, tag_to_match}} and {:error, {:unclosed_block, tag_to_match}}) so
callers of consume_until_end_tag and consume_until_end_block can handle unclosed
tags/blocks instead of crashing.

Comment on lines +82 to +129
defp consume_until_end_tag(tokens, tag_to_match) do
do_consume_until_end_tag(tokens, tag_to_match, [], 0)
end

defp do_consume_until_end_tag([{:start_tag, {tag, _}} = token | rest], tag_to_match, acc, level)
when tag == tag_to_match do
do_consume_until_end_tag(rest, tag_to_match, [token | acc], level + 1)
end

defp do_consume_until_end_tag([{:end_tag, tag} = _token | rest], tag_to_match, acc, level)
when tag == tag_to_match do
if level == 0 do
{Enum.reverse(acc), rest}
else
do_consume_until_end_tag(rest, tag_to_match, [{:end_tag, tag} | acc], level - 1)
end
end

defp do_consume_until_end_tag([token | rest], tag_to_match, acc, level) do
do_consume_until_end_tag(rest, tag_to_match, [token | acc], level)
end

defp consume_until_end_block(tokens, tag_to_match) do
do_consume_until_end_block(tokens, tag_to_match, [], 0)
end

defp do_consume_until_end_block(
[{:block_start, {tag, _}} = token | rest],
tag_to_match,
acc,
level
)
when tag == tag_to_match do
do_consume_until_end_block(rest, tag_to_match, [token | acc], level + 1)
end

defp do_consume_until_end_block([{:block_end, tag} = _token | rest], tag_to_match, acc, level)
when tag == tag_to_match do
if level == 0 do
{Enum.reverse(acc), rest}
else
do_consume_until_end_block(rest, tag_to_match, [{:block_end, tag} | acc], level - 1)
end
end

defp do_consume_until_end_block([token | rest], tag_to_match, acc, level) do
do_consume_until_end_block(rest, tag_to_match, [token | acc], level)
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add explicit base cases for unclosed tags/blocks.
Without a [] clause, Line 83/105 will crash with FunctionClauseError on unbalanced markup rather than a useful error.

🐛 Proposed fix
 defp consume_until_end_tag(tokens, tag_to_match) do
   do_consume_until_end_tag(tokens, tag_to_match, [], 0)
 end
 
+defp do_consume_until_end_tag([], tag_to_match, _acc, _level) do
+  raise Hologram.TemplateSyntaxError, message: "Unclosed </#{tag_to_match}> tag."
+end
+
 defp do_consume_until_end_tag([{:start_tag, {tag, _}} = token | rest], tag_to_match, acc, level)
      when tag == tag_to_match do
   do_consume_until_end_tag(rest, tag_to_match, [token | acc], level + 1)
 end
@@
 defp consume_until_end_block(tokens, tag_to_match) do
   do_consume_until_end_block(tokens, tag_to_match, [], 0)
 end
 
+defp do_consume_until_end_block([], tag_to_match, _acc, _level) do
+  raise Hologram.TemplateSyntaxError, message: "Unclosed {/#{tag_to_match}} block."
+end
+
 defp do_consume_until_end_block(
        [{:block_start, {tag, _}} = token | rest],
        tag_to_match,
        acc,
        level
      )
🤖 Prompt for AI Agents
In `@lib/hologram/template/algebra.ex` around lines 82 - 129, The recursive
consumers do_consume_until_end_tag/4 and do_consume_until_end_block/4 lack base
clauses for an empty token list, causing a FunctionClauseError on unbalanced
markup; add explicit [] clauses to both functions to handle unclosed items (e.g.
defp do_consume_until_end_tag([], tag_to_match, _acc, _level) and defp
do_consume_until_end_block([], tag_to_match, _acc, _level)) and return a clear
error (for example {:error, {:unclosed_tag, tag_to_match}} and {:error,
{:unclosed_block, tag_to_match}}) so callers of consume_until_end_tag and
consume_until_end_block can handle unclosed tags/blocks instead of crashing.

Comment on lines +446 to +469
defp from_attr_value([]), do: empty()

defp from_attr_value(parts) when is_list(parts) do
relevant_parts =
Enum.filter(parts, fn
{:text, t} -> String.trim(t) != ""
{:expression, _} -> true
end)

if relevant_parts == [] do
empty()
else
value =
Enum.map_join(parts, fn
{:text, t} -> t
{:expression, e} -> tighten_expression(e)
end)

if length(relevant_parts) == 1 and elem(hd(relevant_parts), 0) == :expression do
concat(string("="), string(value))
else
concat(string("="), string("\"" <> value <> "\""))
end
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve explicit empty-string attribute values.
When value == "" (e.g., id=""), Line 455 returns empty() and outputs a boolean attribute instead of ="".

🐛 Proposed fix
 defp from_attr_value(parts) when is_list(parts) do
   relevant_parts =
     Enum.filter(parts, fn
       {:text, t} -> String.trim(t) != ""
       {:expression, _} -> true
     end)
 
-  if relevant_parts == [] do
-    empty()
-  else
-    value =
-      Enum.map_join(parts, fn
-        {:text, t} -> t
-        {:expression, e} -> tighten_expression(e)
-      end)
-
-    if length(relevant_parts) == 1 and elem(hd(relevant_parts), 0) == :expression do
-      concat(string("="), string(value))
-    else
-      concat(string("="), string("\"" <> value <> "\""))
-    end
-  end
+  value =
+    Enum.map_join(parts, fn
+      {:text, t} -> t
+      {:expression, e} -> tighten_expression(e)
+    end)
+
+  if relevant_parts == [] do
+    # preserve explicit empty/whitespace-only values
+    concat(string("="), string("\"" <> value <> "\""))
+  else
+    if length(relevant_parts) == 1 and elem(hd(relevant_parts), 0) == :expression do
+      concat(string("="), string(value))
+    else
+      concat(string("="), string("\"" <> value <> "\""))
+    end
+  end
 end
🤖 Prompt for AI Agents
In `@lib/hologram/template/algebra.ex` around lines 446 - 469, from_attr_value
currently treats parts that are entirely empty/whitespace as absent and returns
empty(), which drops explicit empty-string attributes like id=""; change the
logic so that when relevant_parts == [] you first check the original parts for
an explicit {:text, ""} (or any text exactly equal to ""), and if present return
concat(string("="), string("\"\"")) (use the existing concat/string helpers)
instead of empty(); keep the existing flow (using tighten_expression,
hd(relevant_parts), etc.) for other cases so only truly omitted attributes
remain boolean while explicit empty strings are preserved.

Copy link
Owner

@bartblast bartblast left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing this forward, @mwmiller!

Since this is a draft I'll focus on high-level architectural decisions and formatting approach rather than implementation details.

Architecture

The Algebra module currently does two jobs (from what I understand):

  1. Building a nested tree from Parser's flat token list (parse_tokens/1, consume_until_*)
  2. Formatting using Inspect.Algebra

Worth extracting tree-building into a separate Hologram.Template.DOMTree module:

Parser → flat tokens
DOMTree → nested tree
Formatter → formatted string

This makes the tree structure reusable for other purposes and keeps the formatter focused on rendering.

Inspect.Algebra seems like a reasonable choice - we can evaluate as we go if it gives us the flexibility we need.

Formatting heuristics

We should base our formatting behavior on established tools like Prettier rather than inventing our own rules. The current logic expands too aggressively:

<div>a</div>

becomes:

<div>
  a
</div>

Prettier distinguishes between inline elements (where whitespace affects rendering) and block elements (where it doesn't), and tries to fit content on one line when possible. It's worth studying Prettier's HTML formatting approach before finalizing the heuristics here.

@bartblast
Copy link
Owner

PS: The dev branch includes CI improvements that significantly speed up the GitHub Actions workflow and improve the feedback loop. You might want to merge those changes into your branch to benefit from the faster iteration.

The current tests all pass which means producing the desired output

Next up we add some more tests for potentially errant edges to smooth
things out
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants