From 8cfd6110dcd67c53f1484c6ff08a9d44418c0868 Mon Sep 17 00:00:00 2001 From: Javier Garea Date: Tue, 22 Sep 2026 19:29:58 +0000 Subject: [PATCH 01/12] refactor: single pass lexer One combinator per token type. Assisted-by: Caude Code:claude-opus-5 --- lib/makeup/lexers/html_lexer.ex | 325 +++++------- lib/makeup/lexers/html_lexer/combinators.ex | 220 -------- mix.exs | 1 - mix.lock | 1 - .../html_lexer/html_lexer_tokenizer_test.exs | 473 +++++++++--------- test/support/helper.ex | 19 - test/support/html_generators.ex | 342 ------------- 7 files changed, 352 insertions(+), 1029 deletions(-) delete mode 100644 test/support/helper.ex delete mode 100644 test/support/html_generators.ex diff --git a/lib/makeup/lexers/html_lexer.ex b/lib/makeup/lexers/html_lexer.ex index 3af1429..fb6e2ad 100644 --- a/lib/makeup/lexers/html_lexer.ex +++ b/lib/makeup/lexers/html_lexer.ex @@ -10,93 +10,132 @@ defmodule Makeup.Lexers.HTMLLexer do import Makeup.Lexer.Groups import Makeup.Lexers.HTMLLexer.Combinators - @attributes MapSet.new(get_attributes() ++ get_event_handler_attributes()) - ################################################################### # Step #1: tokenize the input (into a list of tokens) ################################################################### # Whitespaces - wspace = ascii_string([?\r, ?\s, ?\n, ?\f], min: 1) + wspace = ascii_string([?\r, ?\s, ?\n, ?\t, ?\f], min: 1) + whitespace = token(wspace, :whitespace) - whitespace = - wspace - |> token(:whitespace) + # Comment + comment = + string("")) |> utf8_string([], 1)) + |> optional(string("-->")) + |> token(:comment_multiline) + + # CDATA section + cdata = + string(" repeat(lookahead_not(string("]]>")) |> utf8_string([], 1)) + |> optional(string("]]>")) + |> token(:comment_preproc) - # Doctype - legacy_doctype_string = - wspace - |> optional() - |> concat(anycase_string("SYSTEM")) - |> optional(wspace) - |> concat( - choice([ - string("\"about:legacy-compat\""), - string("'about:legacy-compat'") - ]) - ) + # Processing instruction + processing_instruction = + string(" repeat(utf8_string([not: ?>], 1)) + |> optional(string(">")) + |> token(:comment_preproc) + + # Character reference + entity = + string("&") + |> concat(ascii_string([?a..?z, ?A..?Z, ?0..?9, ?#], min: 1)) + |> concat(string(";")) + |> token(:name_entity) + # Doctype doctype = - " string() + string(" concat(anycase_string("DOCTYPE")) - |> optional(wspace) - |> concat(anycase_string("html")) - |> optional(legacy_doctype_string) - |> optional(wspace) - |> concat(string(">")) + |> repeat(utf8_string([not: ?>], 1)) + |> optional(string(">")) |> token(:comment_preproc) - # Operators - operators = - "=" - |> string() - |> token(:operator) + # `.input` and `:let` for the HEEx lexer + tag_name_chars = [?a..?z, ?A..?Z, ?0..?9, ?_, ?-, ?:, ?.] + tag_name = ascii_string(tag_name_chars, min: 1) - # Combinators that highlight expressions surrounded by a pair of delimiters. - comment_tag = - string("")) - |> utf8_string([], 1) + attribute_name = + utf8_string([not: ?\s, not: ?\n, not: ?\r, not: ?\t, not: ?\f, not: ?/, not: ?>, not: ?=], + min: 1 + ) + + quoted_attribute_value = + choice([ + string("\"") |> repeat(utf8_string([not: ?"], 1)) |> optional(string("\"")), + string("'") |> repeat(utf8_string([not: ?'], 1)) |> optional(string("'")) + ]) + |> token(:string) + + unquoted_attribute_value = + utf8_string([not: ?\s, not: ?\n, not: ?\r, not: ?\t, not: ?\f, not: ?>], min: 1) + |> token(:string) + + # A space may precede a quoted value, not an unquoted one: `field= name="x"` + attribute = + token(attribute_name, :name_attribute) + |> optional( + optional(whitespace) + |> concat(token(string("="), :operator)) + |> optional( + choice([ + optional(whitespace) |> concat(quoted_attribute_value), + unquoted_attribute_value + ]) ) ) - |> string("-->") - |> token(:comment) - name_tag = - ascii_string([?a..?z, ?A..?Z, ?0..?9, ?_, ?-, ?:, ?.], min: 1) - |> token(:name_tag) + # A solidus that does not close the tag, as in `` + stray_solidus = lookahead_not(string("/>")) |> concat(token(string("/"), :punctuation)) - # Single punctuation symbols - open_tag = - "<" - |> string() - |> token(:punctuation) - |> concat(name_tag) + tag_attributes = repeat(choice([whitespace, attribute, stray_solidus])) + # End tag close_tag = - ">" - |> string() - |> token(:punctuation) - - close_self_tag = - "/>" - |> string() - |> token(:punctuation) + token(string(" concat(token(tag_name, :name_tag)) + |> optional(whitespace) + |> concat(token(string(">"), :punctuation)) - open_closing_tag = - " string() - |> token(:punctuation) - |> concat(name_tag) + # Start tag + open_tag = + token(string("<"), :punctuation) + |> concat(token(tag_name, :name_tag)) + |> concat(tag_attributes) + |> concat(token(choice([string("/>"), string(">")]), :punctuation)) + + # `<` and `>` are ordinary operators in JavaScript and CSS, so a script or + # style body is one opaque token that ends only at its own close tag + raw_element = fn name -> + open = + token(string("<"), :punctuation) + |> concat(token(anycase_string(name), :name_tag)) + |> lookahead_not(ascii_char(tag_name_chars)) + |> concat(tag_attributes) + |> concat(token(string(">"), :punctuation)) + + close = + token(string(" concat(token(anycase_string(name), :name_tag)) + |> optional(whitespace) + |> concat(token(string(">"), :punctuation)) + + body = + times(lookahead_not(close) |> utf8_string([], 1), min: 1) + |> reduce({Enum, :join, [""]}) + |> token(:text) + + open |> optional(body) |> optional(close) + end - # Currently we match attributes anywhere in the text - attributes = utf8_string([?a..?z, ?A..?Z, ?0..?9, ?_, ?-, ?:, ?.], min: 1) |> token(:keyword) + # Text + text = utf8_string([not: ?<, not: ?&], min: 1) |> token(:text) # Unmatched - insensitive_char = utf8_char([]) |> token(:char) + any_char = utf8_char([]) |> token(:text) # Tag the tokens with the language name. # This makes it easier to postprocess files with multiple languages. @@ -105,22 +144,23 @@ defmodule Makeup.Lexers.HTMLLexer do {ttype, Map.put(meta, :language, :html), value} end + # First match wins, not longest, so: longest prefix first root_element_combinator = choice([ - # Doctype + # Markup declarations + comment, + cdata, doctype, - # Operators - operators, - # Delimiters - comment_tag, - open_closing_tag, - open_tag, - close_self_tag, + processing_instruction, + # Tags + raw_element.("script"), + raw_element.("style"), close_tag, + open_tag, # Text - whitespace, - attributes, - insensitive_char + entity, + text, + any_char ]) ############################################################################## @@ -134,149 +174,24 @@ defmodule Makeup.Lexers.HTMLLexer do defparsec( :root_element, root_element_combinator |> map({__MODULE__, :__as_html_language__, []}), - inline: @inline + inline: @inline, + export_combinator: true ) # @impl Makeup.Lexer defparsec( :root, repeat(parsec(:root_element)), - inline: @inline + inline: @inline, + export_combinator: true ) ################################################################### # Step #2: postprocess the list of tokens ################################################################### - # Converts traces of the form [char]+ into a single string - # Converts keywords before and after strings into a single string - - defp merge([{:char, attr, value} | tokens]), - do: merge([{:string, attr, <>} | tokens]) - - defp merge([{tag, attr, value1}, {:char, _attr, value2} | tokens]) - when tag in [:keyword, :string], - do: merge([{:string, attr, <>} | tokens]) - - defp merge([{tag, attr, value1}, {:keyword, _attr, value2} | tokens]) - when tag in [:keyword, :string], - do: merge([{:string, attr, value1 <> value2} | tokens]) - - defp merge([{tag, attr, value1}, {:string, _attr, value2} | tokens]) - when tag in [:keyword, :string], - do: merge([{:string, attr, value1 <> value2} | tokens]) - - defp merge([token | tokens]), - do: [token | merge(tokens)] - - defp merge([]), do: [] - - # Converts the proper keywords into attributes - - defp attributify( - [ - {:punctuation, _, "<"} = punctuation, - {:name_tag, _, _} = name_tag, - {:whitespace, _, _} = whitespace | tokens - ], - _ - ) do - [punctuation, name_tag, whitespace | attributify(tokens, true)] - end - - defp attributify([{:punctuation, _, ">"} = punctuation | tokens], _flag), - do: [punctuation | attributify(tokens, false)] - - defp attributify([{:punctuation, _, "/>"} = punctuation | tokens], _flag), - do: [punctuation | attributify(tokens, false)] - - # when using the HTMLLexer from HEEx, we often have to deal with - # strings like
...
where an attribute starts, but is missing - # a value. We special case this "missing attribute value" case here to avoid - # formatting the closing tag as an attribute. - defp attributify( - [ - {:keyword, attr, value}, - {:operator, _, _} = operator, - {:punctuation, attr2, value2} | tokens - ], - true - ) do - [ - {:name_attribute, attr, value}, - operator, - {:punctuation, attr2, value2} - | attributify(tokens, false) - ] - end - - defp attributify( - [ - {:keyword, attr, value}, - {:operator, _, _} = operator, - {_, attr2, value2} | tokens - ], - true - ) do - [ - {:name_attribute, attr, value}, - operator, - {:string, attr2, value2} - | attributify(tokens, true) - ] - end - - defp attributify([{:keyword, attr, value} | tokens], true), - do: [{:name_attribute, attr, value} | attributify(tokens, true)] - - defp attributify([{:keyword, attr, value} | tokens], flag) do - attribute = - if Enum.member?(@attributes, value), - do: {:name_attribute, attr, value}, - else: {:string, attr, value} - - [attribute | attributify(tokens, flag)] - end - - defp attributify([token | tokens], flag), - do: [token | attributify(tokens, flag)] - - defp attributify([], _), do: [] - - # Converts the content of an element into a string - - defp buffer_to_acc("", acc), do: acc - defp buffer_to_acc(string, acc), do: [{:string, %{language: :html}, string} | acc] - - defp stringify([{:punctuation, _, ">"} = punctuation | tokens], _, buffer), - do: buffer_to_acc(buffer, [punctuation | stringify(tokens, true, "")]) - - # We respect the comments - defp stringify([{:comment, _, _} = comment | tokens], _, buffer), - do: buffer_to_acc(buffer, [comment | stringify(tokens, true, "")]) - - defp stringify([{:punctuation, _, " value) - - defp stringify([], _, buffer), - do: buffer_to_acc(buffer, []) - @impl Makeup.Lexer - def postprocess(tokens, _opts \\ []) do - tokens - |> merge() - |> attributify(false) - |> stringify(false, "") - end + def postprocess(tokens, _opts \\ []), do: tokens ####################################################################### # Step #3: highlight matching delimiters diff --git a/lib/makeup/lexers/html_lexer/combinators.ex b/lib/makeup/lexers/html_lexer/combinators.ex index e9e4452..084bde9 100644 --- a/lib/makeup/lexers/html_lexer/combinators.ex +++ b/lib/makeup/lexers/html_lexer/combinators.ex @@ -1,9 +1,6 @@ defmodule Makeup.Lexers.HTMLLexer.Combinators do @moduledoc false import NimbleParsec - import Makeup.Lexer.Combinators - - def keyword(string), do: string |> token(:keyword) # Insensitive string # https://elixirforum.com/t/nimbleparsec-case-insensitive-matches/14339/2 @@ -41,221 +38,4 @@ defmodule Makeup.Lexers.HTMLLexer.Combinators do defp both_cases(c) do [c, c + 32] end - - def get_attributes do - [ - "abbr", - "accept", - "accept-charset", - "accesskey", - "action", - "allow", - "allowfullscreen", - "allowpaymentrequest", - "alt", - "as", - "async", - "autocapitalize", - "autocomplete", - "autofocus", - "autoplay", - "charset", - "checked", - "cite", - "class", - "color", - "cols", - "colspan", - "content", - "contenteditable", - "controls", - "coords", - "crossorigin", - "data", - "datetime", - "decoding", - "default", - "defer", - "dir", - "dirname", - "disabled", - "download", - "draggable", - "enctype", - "enterkeyhint", - "for", - "form", - "formaction", - "formenctype", - "formmethod", - "formnovalidate", - "formtarget", - "headers", - "height", - "hidden", - "high", - "href", - "hreflang", - "http-equiv", - "id", - "imagesizes", - "imagesrcset", - "inputmode", - "integrity", - "is", - "ismap", - "itemid", - "itemprop", - "itemref", - "itemscope", - "itemtype", - "kind", - "label", - "lang", - "list", - "loading", - "loop", - "low", - "manifest", - "max", - "maxlength", - "media", - "method", - "min", - "minlength", - "multiple", - "muted", - "name", - "nomodule", - "nonce", - "novalidate", - "open", - "optimum", - "pattern", - "ping", - "placeholder", - "playsinline", - "poster", - "preload", - "readonly", - "referrerpolicy", - "rel", - "required", - "reversed", - "rows", - "rowspan", - "sandbox", - "scope", - "selected", - "shape", - "size", - "sizes", - "slot", - "span", - "spellcheck", - "src", - "srcdoc", - "srclang", - "srcset", - "start", - "step", - "style", - "tabindex", - "target", - "title", - "translate", - "type", - "usemap", - "value", - "width", - "wrap" - ] - end - - def get_event_handler_attributes do - [ - "onabort", - "onafterprint", - "onauxclick", - "onbeforeprint", - "onbeforeunload", - "onblur", - "oncancel", - "oncanplay", - "oncanplaythrough", - "onchange", - "onclick", - "onclose", - "oncontextmenu", - "oncopy", - "oncuechange", - "oncut", - "ondblclick", - "ondrag", - "ondragend", - "ondragenter", - "ondragexit", - "ondragleave", - "ondragover", - "ondragstart", - "ondrop", - "ondurationchange", - "onemptied", - "onended", - "onerror", - "onfocus", - "onformdata", - "onhashchange", - "oninput", - "oninvalid", - "onkeydown", - "onkeypress", - "onkeyup", - "onlanguagechange", - "onload", - "onloadeddata", - "onloadedmetadata", - "onloadstart", - "onmessage", - "onmessageerror", - "onmousedown", - "onmouseenter", - "onmouseleave", - "onmousemove", - "onmouseout", - "onmouseover", - "onmouseup", - "onoffline", - "ononline", - "onpagehide", - "onpageshow", - "onpaste", - "onpause", - "onplay", - "onplaying", - "onpopstate", - "onprogress", - "onratechange", - "onrejectionhandled", - "onreset", - "onresize", - "onscroll", - "onsecuritypolicyviolation", - "onseeked", - "onseeking", - "onselect", - "onslotchange", - "onstalled", - "onstorage", - "onsubmit", - "onsuspend", - "ontimeupdate", - "ontoggle", - "onunhandledrejection", - "onunload", - "onvolumechange", - "onwaiting", - "onwheel" - ] - end end diff --git a/mix.exs b/mix.exs index 8aeceee..9049814 100644 --- a/mix.exs +++ b/mix.exs @@ -50,7 +50,6 @@ defmodule MakeupHTML.Mixfile do defp deps do [ {:makeup, "~> 1.2"}, - {:stream_data, "~> 1.0", only: :test}, {:ex_doc, "~> 0.24", only: :dev, runtime: false} ] end diff --git a/mix.lock b/mix.lock index 4bd2cae..2913c0b 100644 --- a/mix.lock +++ b/mix.lock @@ -5,5 +5,4 @@ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "stream_data": {:hex, :stream_data, "1.1.2", "05499eaec0443349ff877aaabc6e194e82bda6799b9ce6aaa1aadac15a9fdb4d", [:mix], [], "hexpm", "129558d2c77cbc1eb2f4747acbbea79e181a5da51108457000020a906813a1a9"}, } diff --git a/test/makeup/html_lexer/html_lexer_tokenizer_test.exs b/test/makeup/html_lexer/html_lexer_tokenizer_test.exs index a1b7525..c7b1d6b 100644 --- a/test/makeup/html_lexer/html_lexer_tokenizer_test.exs +++ b/test/makeup/html_lexer/html_lexer_tokenizer_test.exs @@ -1,10 +1,8 @@ defmodule HTMLLexerTokenizer do use ExUnit.Case, async: false - use ExUnitProperties alias Makeup.Lexers.HTMLLexer alias Makeup.Lexer.Postprocess - alias Helper # This function has three purposes: # 1. Ensure deterministic lexer output (no random prefix) @@ -19,45 +17,6 @@ defmodule HTMLLexerTokenizer do |> Enum.map(fn {ttype, meta, value} -> {ttype, Map.delete(meta, :language), value} end) end - defp tokenize_elements(element) do - [element_name | attributes] = - element - |> String.split(" ") - - attributes_tokens = - attributes - |> Enum.flat_map(fn attr -> - [name | value] = - attr - |> String.split("=") - - cond do - value != [] -> - [ - {:whitespace, %{}, " "}, - {:name_attribute, %{}, name}, - {:operator, %{}, "="}, - {:string, %{}, hd(value)} - ] - - String.length(name) == 0 -> - [ - {:whitespace, %{}, " "} - ] - - true -> - [ - {:whitespace, %{}, " "}, - {:name_attribute, %{}, name} - ] - end - end) - - if attributes_tokens == [], - do: {{:name_tag, %{}, element_name}, {:whitespace, %{}, " "}}, - else: {{:name_tag, %{}, element_name}, attributes_tokens} - end - ################################################################### # Empty string ################################################################### @@ -69,18 +28,6 @@ defmodule HTMLLexerTokenizer do # Doctype ################################################################### describe "DOCTYPE" do - property "correct DOCTYPE is correctly tokenized" do - check all(doctype <- HTMLGenerators.doctype()) do - assert lex(doctype) == [{:comment_preproc, %{}, doctype}] - end - end - - property "incorrect DOCTYPE is incorrectly tokenized" do - check all(doctype <- HTMLGenerators.incorrect_doctype()) do - refute lex(doctype) == [{:comment_preproc, %{}, doctype}] - end - end - test "" do doctype = "" @@ -100,53 +47,82 @@ defmodule HTMLLexerTokenizer do # Comment ################################################################### describe "comment" do - property "correct comment is correctly tokenized" do - check all(comment <- HTMLGenerators.comment()) do - assert lex(comment) == [{:comment, %{}, comment}] - end - end - test "" do comment = "" - assert lex(comment) == [{:comment, %{}, ""}] + assert lex(comment) == [ + {:comment_multiline, %{}, ""} + ] end end ################################################################### - # Void element + # Character reference ################################################################### - describe "void element" do - property "correct void_element is correctly tokenized" do - check all(void_element <- HTMLGenerators.void_element()) do - element = - void_element - |> String.replace_prefix("<", "") - |> String.replace_suffix(">", "") - - assert lex(void_element) == [ - {:punctuation, %{group_id: "group-1"}, "<"}, - {:name_tag, %{}, element}, - {:punctuation, %{group_id: "group-1"}, ">"} - ] - end + describe "character reference" do + test "named" do + assert lex("Tom & Jerry") == [ + {:text, %{}, "Tom "}, + {:name_entity, %{}, "&"}, + {:text, %{}, " Jerry"} + ] + end + + test "decimal" do + assert lex("©") == [{:name_entity, %{}, "©"}] + end + + test "hexadecimal" do + assert lex("©") == [{:name_entity, %{}, "©"}] end - property "incorrect void_element is incorrectly tokenized" do - check all(void_element <- HTMLGenerators.incorrect_void_element()) do - element = - void_element - |> String.replace_prefix("<", "") - |> String.replace_suffix(">", "") - - refute lex(void_element) == [ - {:punctuation, %{group_id: "group-1"}, "<"}, - {:name_tag, %{}, element}, - {:punctuation, %{group_id: "group-1"}, ">"} - ] - end + test "an ampersand with no semicolon is text" do + assert lex("a & b") == [{:text, %{}, "a "}, {:text, %{}, "&"}, {:text, %{}, " b"}] end + end + + ################################################################### + # CDATA section + ################################################################### + describe "CDATA section" do + test "" do + cdata = "" + + assert lex(cdata) == [{:comment_preproc, %{}, cdata}] + end + + test "inside a MathML element" do + assert lex("") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "ms"}, + {:punctuation, %{group_id: "group-1"}, ">"}, + {:comment_preproc, %{}, ""}, + {:punctuation, %{group_id: "group-2"}, ""} + ] + end + end + ################################################################### + # Processing instruction + ################################################################### + describe "processing instruction" do + test "with a target only" do + assert lex("") == [{:comment_preproc, %{}, ""}] + end + + test "with data" do + instruction = ~S|| + + assert lex(instruction) == [{:comment_preproc, %{}, instruction}] + end + end + + ################################################################### + # Void element + ################################################################### + describe "void element" do test "
" do void_element = "
" @@ -162,63 +138,49 @@ defmodule HTMLLexerTokenizer do # Attribute ################################################################### describe "attribute" do - property "correct attribute is correctly tokenized" do - check all(attribute <- HTMLGenerators.attribute()) do - [name | value] = - attribute - |> String.split("=") - - if value != [] do - assert lex(attribute) == [ - {:name_attribute, %{}, name}, - {:operator, %{}, "="}, - {:string, %{}, value |> Enum.at(0)} - ] - else - assert lex(attribute) == [ - {:name_attribute, %{}, name} - ] - end - end - end - - test "disabled" do - attribute = "disabled" - - assert lex(attribute) == [ - {:name_attribute, %{}, attribute} + test "" do + assert lex("") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "input"}, + {:whitespace, %{}, " "}, + {:name_attribute, %{}, "disabled"}, + {:punctuation, %{group_id: "group-1"}, ">"} ] end - test "value=yes" do - attribute = "value=yes" - - assert lex(attribute) == [ + test "" do + assert lex("") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "input"}, + {:whitespace, %{}, " "}, {:name_attribute, %{}, "value"}, {:operator, %{}, "="}, - {:string, %{}, "yes"} + {:string, %{}, "yes"}, + {:punctuation, %{group_id: "group-1"}, ">"} ] end - test "type='checkbox'" do - attribute = "type='checkbox'" - - assert lex(attribute) == [ + test "" do + assert lex("") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "input"}, + {:whitespace, %{}, " "}, {:name_attribute, %{}, "type"}, {:operator, %{}, "="}, - {:string, %{}, "'checkbox'"} + {:string, %{}, "'checkbox'"}, + {:punctuation, %{group_id: "group-1"}, ">"} ] end - test "name=\"be evil\"" do - attribute = "name=\"be evil\"" - - assert lex(attribute) == [ + test "" do + assert lex("") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "input"}, + {:whitespace, %{}, " "}, {:name_attribute, %{}, "name"}, {:operator, %{}, "="}, - {:string, %{}, "\"be"}, - {:whitespace, %{}, " "}, - {:string, %{}, "evil\""} + {:string, %{}, "\"be evil\""}, + {:punctuation, %{group_id: "group-1"}, ">"} ] end end @@ -227,77 +189,6 @@ defmodule HTMLLexerTokenizer do # Single element ################################################################### describe "single element" do - property "correct single element is correctly tokenized" do - check all(single_element <- HTMLGenerators.single_element()) do - if String.ends_with?(single_element, "/>") do - element_opening = - single_element - |> String.replace_prefix("<", "") - |> String.replace_suffix("/>", "") - - {element, attributes_tokens} = tokenize_elements(element_opening) - - assert lex(single_element) == - [ - {:punctuation, %{group_id: "group-1"}, "<"}, - element - ] ++ - attributes_tokens ++ - [ - {:punctuation, %{group_id: "group-1"}, "/>"} - ] - else - [head | tail] = - single_element - |> String.split(">", trim: true) - - element_opening = - head - |> String.replace_prefix("<", "") - - {element, attributes_tokens} = tokenize_elements(element_opening) - - if tail != [] do - [element_content | rest] = - tail - |> Enum.at(0) - |> String.split(""} - ] ++ - content_tokens ++ - [ - {:punctuation, %{group_id: "group-2"}, ""} - ] - else - assert lex(single_element) == - [ - {:punctuation, %{group_id: "group-1"}, "<"}, - element - ] ++ - attributes_tokens ++ - [ - {:punctuation, %{group_id: "group-1"}, ">"} - ] - end - end - end - end - test "" do element = "" @@ -320,7 +211,7 @@ defmodule HTMLLexerTokenizer do {:punctuation, %{group_id: "group-1"}, "<"}, {:name_tag, %{}, "title"}, {:punctuation, %{group_id: "group-1"}, ">"}, - {:string, %{}, "Hello"}, + {:text, %{}, "Hello"}, {:punctuation, %{group_id: "group-2"}, ""} @@ -378,7 +269,7 @@ defmodule HTMLLexerTokenizer do {:punctuation, %{group_id: "group-2"}, "<"}, {:name_tag, %{}, "title"}, {:punctuation, %{group_id: "group-2"}, ">"}, - {:string, %{}, "Hello"}, + {:text, %{}, "Hello"}, {:punctuation, %{group_id: "group-3"}, ""}, @@ -405,6 +296,86 @@ defmodule HTMLLexerTokenizer do end end + ################################################################### + # Raw text element + ################################################################### + describe "raw text element" do + test "" do + assert lex("") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "style"}, + {:punctuation, %{group_id: "group-1"}, ">"}, + {:text, %{}, "ul > li { color: red }"}, + {:punctuation, %{group_id: "group-2"}, ""} + ] + end + + test "comparison operators in a script are not tags" do + assert lex("") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "script"}, + {:punctuation, %{group_id: "group-1"}, ">"}, + {:text, %{}, "if (ad) f()"}, + {:punctuation, %{group_id: "group-2"}, ""} + ] + end + + test "an end tag inside a string is not a tag" do + assert lex(~S||) == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "script"}, + {:punctuation, %{group_id: "group-1"}, ">"}, + {:text, %{}, ~S|w("

")|}, + {:punctuation, %{group_id: "group-2"}, ""} + ] + end + + test "with attributes and no body" do + assert lex(~S||) == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "script"}, + {:whitespace, %{}, " "}, + {:name_attribute, %{}, "src"}, + {:operator, %{}, "="}, + {:string, %{}, ~S|"a.js"|}, + {:punctuation, %{group_id: "group-1"}, ">"}, + {:punctuation, %{group_id: "group-2"}, ""} + ] + end + + test "the close tag is matched case-insensitively" do + assert lex("") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "SCRIPT"}, + {:punctuation, %{group_id: "group-1"}, ">"}, + {:text, %{}, "x"}, + {:punctuation, %{group_id: "group-2"}, ""} + ] + end + + test "a longer name that starts with script is an ordinary element" do + assert lex("a") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "scripty"}, + {:punctuation, %{group_id: "group-1"}, ">"}, + {:text, %{}, "a"}, + {:punctuation, %{group_id: "group-2"}, ""} + ] + end + end + ################################################################### # Document ################################################################### @@ -429,49 +400,63 @@ defmodule HTMLLexerTokenizer do assert lex(document) == [ {:comment_preproc, %{}, ""}, - {:whitespace, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-1"}, "<"}, {:name_tag, %{}, "html"}, {:punctuation, %{group_id: "group-1"}, ">"}, - {:string, %{}, "\n "}, - {:comment, %{}, ""}, - {:string, %{}, "\n "}, + {:text, %{}, " + "}, + {:comment_multiline, %{}, ""}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-2"}, "<"}, {:name_tag, %{}, "head"}, {:punctuation, %{group_id: "group-2"}, ">"}, - {:string, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-3"}, "<"}, {:name_tag, %{}, "title"}, {:punctuation, %{group_id: "group-3"}, ">"}, - {:string, %{}, "\n Hello\n "}, + {:text, %{}, " + Hello + "}, {:punctuation, %{group_id: "group-4"}, ""}, - {:string, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-5"}, ""}, - {:string, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-6"}, "<"}, {:name_tag, %{}, "body"}, {:punctuation, %{group_id: "group-6"}, ">"}, - {:string, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-7"}, "<"}, {:name_tag, %{}, "p"}, {:punctuation, %{group_id: "group-7"}, ">"}, - {:string, %{}, "\n Welcome to this example.\n "}, + {:text, %{}, " + Welcome to this example. + "}, {:punctuation, %{group_id: "group-8"}, ""}, - {:string, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-9"}, ""}, - {:string, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-10"}, ""}, - {:string, %{}, "\n"} + {:text, %{}, " +"} ] end end @@ -501,11 +486,12 @@ defmodule HTMLLexerTokenizer do {:whitespace, %{}, " "}, {:name_attribute, %{}, ":let"}, {:operator, %{}, "="}, - {:string, %{}, " "}, + {:whitespace, %{}, " "}, {:name_attribute, %{}, "field"}, {:operator, %{}, "="}, {:punctuation, %{group_id: "group-1"}, ">"}, - {:string, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-2"}, "<"}, {:name_tag, %{}, "input"}, {:whitespace, %{}, " "}, @@ -519,9 +505,10 @@ defmodule HTMLLexerTokenizer do {:whitespace, %{}, " "}, {:name_attribute, %{}, "value"}, {:operator, %{}, "="}, - {:string, %{}, " "}, + {:whitespace, %{}, " "}, {:punctuation, %{group_id: "group-2"}, "/>"}, - {:whitespace, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-3"}, "<"}, {:name_tag, %{}, ".input"}, {:whitespace, %{}, " "}, @@ -531,13 +518,14 @@ defmodule HTMLLexerTokenizer do {:whitespace, %{}, " "}, {:name_attribute, %{}, "field"}, {:operator, %{}, "="}, - {:string, %{}, " "}, + {:whitespace, %{}, " "}, {:name_attribute, %{}, "placeholder"}, {:operator, %{}, "="}, {:string, %{}, "\"email\""}, {:whitespace, %{}, " "}, {:punctuation, %{group_id: "group-3"}, "/>"}, - {:whitespace, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-4"}, "<"}, {:name_tag, %{}, ".input"}, {:whitespace, %{}, " "}, @@ -547,17 +535,19 @@ defmodule HTMLLexerTokenizer do {:whitespace, %{}, " "}, {:name_attribute, %{}, "field"}, {:operator, %{}, "="}, - {:string, %{}, " "}, + {:whitespace, %{}, " "}, {:name_attribute, %{}, "placeholder"}, {:operator, %{}, "="}, {:string, %{}, "\"name\""}, {:whitespace, %{}, " "}, {:punctuation, %{group_id: "group-4"}, "/>"}, - {:whitespace, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-5"}, "<"}, {:name_tag, %{}, "label"}, {:punctuation, %{group_id: "group-5"}, ">"}, - {:string, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-6"}, "<"}, {:name_tag, %{}, "input"}, {:whitespace, %{}, " "}, @@ -571,33 +561,35 @@ defmodule HTMLLexerTokenizer do {:whitespace, %{}, " "}, {:name_attribute, %{}, "value"}, {:operator, %{}, "="}, - {:string, %{}, " "}, + {:whitespace, %{}, " "}, {:name_attribute, %{}, "class"}, {:operator, %{}, "="}, {:string, %{}, "\"hidden\""}, {:whitespace, %{}, " "}, {:punctuation, %{group_id: "group-6"}, "/>"}, - {:whitespace, %{}, "\n "}, - {:string, %{}, "delete"}, - {:whitespace, %{}, "\n "}, + {:text, %{}, " + delete + "}, {:punctuation, %{group_id: "group-7"}, ""}, - {:string, %{}, "\n"}, + {:text, %{}, " +"}, {:punctuation, %{group_id: "group-8"}, ""}, - {:string, %{}, "\n\n"}, + {:text, %{}, " + +"}, {:punctuation, %{group_id: "group-9"}, "<"}, {:name_tag, %{}, "label"}, {:whitespace, %{}, " "}, {:name_attribute, %{}, "class"}, {:operator, %{}, "="}, - {:string, %{}, "\"block"}, - {:whitespace, %{}, " "}, - {:string, %{}, "cursor-pointer\""}, + {:string, %{}, "\"block cursor-pointer\""}, {:punctuation, %{group_id: "group-9"}, ">"}, - {:string, %{}, "\n "}, + {:text, %{}, " + "}, {:punctuation, %{group_id: "group-10"}, "<"}, {:name_tag, %{}, "input"}, {:whitespace, %{}, " "}, @@ -614,15 +606,14 @@ defmodule HTMLLexerTokenizer do {:string, %{}, "\"hidden\""}, {:whitespace, %{}, " "}, {:punctuation, %{group_id: "group-10"}, "/>"}, - {:whitespace, %{}, "\n "}, - {:string, %{}, "add"}, - {:whitespace, %{}, " "}, - {:string, %{}, "more"}, - {:whitespace, %{}, "\n"}, + {:text, %{}, " + add more +"}, {:punctuation, %{group_id: "group-11"}, ""}, - {:string, %{}, "\n"} + {:text, %{}, " +"} ] = lex(element) end end diff --git a/test/support/helper.ex b/test/support/helper.ex deleted file mode 100644 index 9863be9..0000000 --- a/test/support/helper.ex +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Helper do - @moduledoc false - - def insensitive_case_string(string) do - string - |> String.split("", trim: true) - |> insensitive_case_string([]) - |> Enum.join("") - end - - def insensitive_case_string([], result), do: result - - def insensitive_case_string([h | t], result) do - insensitive_case_string(t, result ++ [insensitive_fun(Enum.random(0..1), h)]) - end - - defp insensitive_fun(0, string), do: String.downcase(string) - defp insensitive_fun(1, string), do: String.upcase(string) -end diff --git a/test/support/html_generators.ex b/test/support/html_generators.ex deleted file mode 100644 index 3be44be..0000000 --- a/test/support/html_generators.ex +++ /dev/null @@ -1,342 +0,0 @@ -defmodule HTMLGenerators do - @moduledoc false - use ExUnitProperties - - alias Makeup.Lexers.HTMLLexer.Combinators - - @attributes Combinators.get_attributes() ++ Combinators.get_event_handler_attributes() - - defp get_attributes do - @attributes - end - - defp get_elements do - [ - "a", - "abbr", - "address", - "area", - "article", - "aside", - "audio", - "b", - "base", - "bdi", - "bdo", - "blockquote", - "body", - "br", - "button", - "canvas", - "caption", - "cite", - "code", - "col", - "colgroup", - "data", - "datalist", - "dd", - "del", - "details", - "dfn", - "dialog", - "div", - "dl", - "dt", - "em", - "embed", - "fieldset", - "figcaption", - "figure", - "footer", - "form", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "head", - "header", - "hgroup", - "hr", - "html", - "i", - "iframe", - "img", - "input", - "ins", - "kbd", - "label", - "legend", - "li", - "link", - "main", - "map", - "mark", - "math", - "menu", - "meta", - "meter", - "nav", - "noscript", - "object", - "ol", - "optgroup", - "option", - "output", - "p", - "param", - "picture", - "pre", - "progress", - "q", - "rp", - "rt", - "ruby", - "s", - "samp", - "script", - "section", - "select", - "slot", - "small", - "source", - "span", - "strong", - "style", - "sub", - "summary", - "sup", - "svg", - "table", - "tbody", - "td", - "template", - "textarea", - "tfoot", - "th", - "thead", - "time", - "title", - "tr", - "track", - "u", - "ul", - "var", - "video", - "wbr" - ] - end - - defp insensitive_case_string(string) do - string - |> String.split("", trim: true) - |> insensitive_case_string([]) - |> Enum.join("") - end - - defp insensitive_case_string([], result), do: result - - defp insensitive_case_string([h | t], result) do - insensitive_case_string(t, result ++ [insensitive_fun(Enum.random(0..1), h)]) - end - - defp insensitive_fun(0, string), do: String.downcase(string) - defp insensitive_fun(1, string), do: String.upcase(string) - - ## Generators - - def doctype_legacy_string do - ExUnitProperties.gen all( - one_or_more <- StreamData.integer(1..5), - quotation <- StreamData.member_of(["\"", "\'"]) - ) do - String.duplicate(" ", one_or_more) <> - insensitive_case_string("SYSTEM") <> - String.duplicate(" ", one_or_more) <> quotation <> "about:legacy-compat" <> quotation - end - end - - def doctype do - ExUnitProperties.gen all( - one_or_more <- StreamData.integer(1..5), - optional <- StreamData.integer(0..5), - legacy_string <- doctype_legacy_string() - ) do - " - insensitive_case_string("DOCTYPE") <> - String.duplicate(" ", one_or_more) <> - insensitive_case_string("html") <> - legacy_string <> - String.duplicate(" ", optional) <> ">" - end - end - - def comment do - ExUnitProperties.gen all(gen_text <- StreamData.string(:ascii)) do - text = - gen_text - |> String.replace_leading(">", "") - |> String.replace_leading("->", "") - |> String.replace("", "") - |> String.replace("--!>", "") - |> String.replace_trailing(" text <> "-->" - end - end - - def void_element do - ExUnitProperties.gen all(element <- StreamData.member_of(get_elements())) do - "<" <> element <> ">" - end - end - - def attribute do - ExUnitProperties.gen all( - quotation <- StreamData.member_of(["\"", "\'", ""]), - name <- StreamData.member_of(get_attributes()), - value <- StreamData.string(:alphanumeric), - value not in get_attributes() - ) do - if String.length(value) != 0, - do: name <> "=" <> quotation <> value <> quotation, - else: name - end - end - - def element_attribute do - ExUnitProperties.gen all( - quotation <- StreamData.member_of(["\"", "\'", ""]), - name <- StreamData.member_of(get_attributes()), - value <- StreamData.string(:alphanumeric), - value not in get_attributes() - ) do - if String.length(value) != 0, - do: name <> "=" <> quotation <> value <> quotation, - else: name - end - end - - def single_element do - ExUnitProperties.gen all( - element_name <- StreamData.member_of(get_elements()), - content <- StreamData.string(:ascii), - attributes <- StreamData.list_of(element_attribute(), max_length: 3), - attributes_string <- - StreamData.constant(" " <> Enum.join(attributes, " ")), - element <- - StreamData.member_of([ - "<" <> - element_name <> - attributes_string <> - ">" <> - (content - |> String.replace("<", "") - # TODO: Element content can contain ">" - |> String.replace(">", "")) <> - " element_name <> ">", - "<" <> element_name <> attributes_string <> "/>", - "<" <> element_name <> attributes_string <> ">" - ]) - ) do - element - end - end - - def nested_element do - ExUnitProperties.gen all( - element_name <- StreamData.member_of(get_elements()), - content <- StreamData.one_of([void_element(), single_element()]), - attributes <- StreamData.list_of(element_attribute(), max_length: 3) - ) do - "<" <> - element_name <> - " " <> - Enum.join(attributes, " ") <> - ">" <> content <> " element_name <> ">" - end - end - - def element do - ExUnitProperties.gen all( - element <- - StreamData.one_of([ - void_element(), - single_element(), - nested_element() - ]) - ) do - element - end - end - - def document do - ExUnitProperties.gen all( - bom <- StreamData.member_of([:unicode.encoding_to_bom(:utf8), ""]), - any_comments <- StreamData.list_of(comment(), max_length: 3), - doctype <- doctype(), - document_content <- StreamData.list_of(element(), max_length: 3) - ) do - comments = Enum.join(any_comments, "\n") - document = "\n" <> Enum.join(document_content, "\n") <> "\n" - - bom <> - comments <> "\n" <> doctype <> "\n" <> comments <> "\n" <> document <> "\n" <> comments - end - end - - ################################################################### - # Incorrect - ################################################################### - - def incorrect_doctype do - ExUnitProperties.gen all(doctype <- doctype()) do - doctype - |> String.replace_suffix(">", "") - end - end - - def incorrect_void_element do - ExUnitProperties.gen all(void_element <- void_element()) do - void_element - |> String.replace_suffix(">", "") - end - end - - def incorrect_single_element do - ExUnitProperties.gen all(single_element <- single_element()) do - single_element - |> String.replace_suffix(">", "") - end - end - - def incorrect_nested_element do - ExUnitProperties.gen all(nested_element <- nested_element()) do - nested_element - |> String.replace_suffix(">", "") - end - end - - def incorrect_element do - ExUnitProperties.gen all( - incorrect_element <- - StreamData.one_of([ - incorrect_void_element(), - incorrect_single_element(), - incorrect_nested_element() - ]) - ) do - incorrect_element - end - end - - def incorrect_document do - ExUnitProperties.gen all(document <- document()) do - document - |> String.replace("<", "") - end - end -end From a0a8647cea559c9bba26c45e4adec7160fb2291a Mon Sep 17 00:00:00 2001 From: Javier Garea Date: Tue, 29 Sep 2026 19:07:00 +0200 Subject: [PATCH 02/12] fix: classify tag names and unmatched characters --- lib/makeup/lexers/html_lexer.ex | 9 ++-- .../html_lexer/html_lexer_tokenizer_test.exs | 49 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/lib/makeup/lexers/html_lexer.ex b/lib/makeup/lexers/html_lexer.ex index fb6e2ad..6820e6b 100644 --- a/lib/makeup/lexers/html_lexer.ex +++ b/lib/makeup/lexers/html_lexer.ex @@ -54,9 +54,12 @@ defmodule Makeup.Lexers.HTMLLexer do |> optional(string(">")) |> token(:comment_preproc) - # `.input` and `:let` for the HEEx lexer tag_name_chars = [?a..?z, ?A..?Z, ?0..?9, ?_, ?-, ?:, ?.] - tag_name = ascii_string(tag_name_chars, min: 1) + # A tag name cannot start with a digit. `.` and `:` are for HEEx function + # components (`<.input>`) and slots (`<:inner_block>`). + tag_name = + lookahead(ascii_char([?a..?z, ?A..?Z, ?., ?:])) + |> concat(ascii_string(tag_name_chars, min: 1)) attribute_name = utf8_string([not: ?\s, not: ?\n, not: ?\r, not: ?\t, not: ?\f, not: ?/, not: ?>, not: ?=], @@ -135,7 +138,7 @@ defmodule Makeup.Lexers.HTMLLexer do text = utf8_string([not: ?<, not: ?&], min: 1) |> token(:text) # Unmatched - any_char = utf8_char([]) |> token(:text) + any_char = utf8_string([], 1) |> token(:text) # Tag the tokens with the language name. # This makes it easier to postprocess files with multiple languages. diff --git a/test/makeup/html_lexer/html_lexer_tokenizer_test.exs b/test/makeup/html_lexer/html_lexer_tokenizer_test.exs index c7b1d6b..1786ae0 100644 --- a/test/makeup/html_lexer/html_lexer_tokenizer_test.exs +++ b/test/makeup/html_lexer/html_lexer_tokenizer_test.exs @@ -119,6 +119,55 @@ defmodule HTMLLexerTokenizer do end end + ################################################################### + # Tag name + ################################################################### + describe "tag name" do + test "a digit does not start a tag" do + assert lex("

1<2

") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "p"}, + {:punctuation, %{group_id: "group-1"}, ">"}, + {:text, %{}, "1"}, + {:text, %{}, "<"}, + {:text, %{}, "2"}, + {:punctuation, %{group_id: "group-2"}, ""} + ] + end + + test "a HEEx function component" do + assert lex("<.input />") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, ".input"}, + {:whitespace, %{}, " "}, + {:punctuation, %{group_id: "group-1"}, "/>"} + ] + end + + test "a HEEx slot" do + assert lex("<:inner_block>x") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, ":inner_block"}, + {:punctuation, %{group_id: "group-1"}, ">"}, + {:text, %{}, "x"}, + {:punctuation, %{group_id: "group-2"}, ""} + ] + end + + test "a namespaced tag" do + assert lex("") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "svg:rect"}, + {:whitespace, %{}, " "}, + {:punctuation, %{group_id: "group-1"}, "/>"} + ] + end + end + ################################################################### # Void element ################################################################### From ac5298492af5645480c7696e69e60e3217122d73 Mon Sep 17 00:00:00 2001 From: Javier Garea Date: Tue, 29 Sep 2026 19:07:00 +0200 Subject: [PATCH 03/12] test: add property tests and update the CI matrix --- .github/workflows/ci.yml | 67 +++++++-- mix.exs | 3 +- mix.lock | 3 +- .../html_lexer/html_lexer_property_test.exs | 28 ++++ test/support/html_generators.ex | 130 ++++++++++++++++++ 5 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 test/makeup/html_lexer/html_lexer_property_test.exs create mode 100644 test/support/html_generators.ex diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc1320f..6c67d03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,58 @@ -name: Continuous Integration +name: CI -on: push +on: + pull_request: + push: + branches: + - main jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + env: + MIX_ENV: test + strategy: + fail-fast: false + matrix: + include: + - pair: + elixir: "1.15" + otp: "24" + - pair: + elixir: "1.18" + otp: "27" + - pair: + elixir: "1.20" + otp: "29" + lint: lint steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup Elixir - uses: erlef/setup-beam@v1 + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.pair.otp}} + elixir-version: ${{matrix.pair.elixir}} + + - uses: actions/cache@v4 with: - otp-version: '24' - elixir-version: '1.12.1' - - name: Install dependencies - run: mix deps.get - - name: Run tests - run: mix test - - name: Check format - run: mix format --check-formatted \ No newline at end of file + path: | + deps + _build + key: ${{ runner.os }}-mix-${{matrix.pair.elixir}}-${{matrix.pair.otp}}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix- + + - run: mix deps.get + + - run: mix format --check-formatted + if: ${{ matrix.lint }} + + - run: mix deps.unlock --check-unused + if: ${{ matrix.lint }} + + - run: mix deps.compile + + - run: mix compile --warnings-as-errors + if: ${{ matrix.lint }} + + - run: mix test diff --git a/mix.exs b/mix.exs index 9049814..e007f2c 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule MakeupHTML.Mixfile do [ app: :makeup_html, version: "0.2.0", - elixir: "~> 1.10", + elixir: "~> 1.12", start_permanent: Mix.env() == :prod, deps: deps(), elixirc_paths: elixirc_paths(Mix.env()), @@ -50,6 +50,7 @@ defmodule MakeupHTML.Mixfile do defp deps do [ {:makeup, "~> 1.2"}, + {:stream_data, "~> 1.0", only: :test}, {:ex_doc, "~> 0.24", only: :dev, runtime: false} ] end diff --git a/mix.lock b/mix.lock index 2913c0b..d630807 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,9 @@ %{ "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, "ex_doc": {:hex, :ex_doc, "0.37.0", "970f92b39e62c460aa8a367508e938f5e4da6e2ff3eaed3f8530b25870f45471", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "b0ee7f17373948e0cf471e59c3a0ee42f3bd1171c67d91eb3626456ef9c6202c"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, } diff --git a/test/makeup/html_lexer/html_lexer_property_test.exs b/test/makeup/html_lexer/html_lexer_property_test.exs new file mode 100644 index 0000000..44537e1 --- /dev/null +++ b/test/makeup/html_lexer/html_lexer_property_test.exs @@ -0,0 +1,28 @@ +defmodule HTMLLexerProperty do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Makeup.Formatters.HTML.HTMLFormatter + alias Makeup.Lexer + alias Makeup.Lexers.HTMLLexer + + property "lexing is lossless" do + check all(source <- HTMLGenerators.document(), max_runs: 2_000) do + assert source |> HTMLLexer.lex() |> Lexer.unlex() == source + end + end + + property "every token value is iodata" do + check all(source <- HTMLGenerators.document(), max_runs: 2_000) do + for {_ttype, _meta, value} <- HTMLLexer.lex(source) do + assert is_binary(value) or is_list(value) + end + end + end + + property "the formatter renders every token stream" do + check all(source <- HTMLGenerators.document(), max_runs: 2_000) do + assert is_binary(HTMLFormatter.format_as_binary(HTMLLexer.lex(source))) + end + end +end diff --git a/test/support/html_generators.ex b/test/support/html_generators.ex new file mode 100644 index 0000000..c7c31b7 --- /dev/null +++ b/test/support/html_generators.ex @@ -0,0 +1,130 @@ +defmodule HTMLGenerators do + @moduledoc false + use ExUnitProperties + + def tag_name do + gen all( + first <- string([?a..?z], length: 1), + rest <- string([?a..?z, ?0..?9, ?-], max_length: 5) + ) do + first <> rest + end + end + + def attribute_name do + string([?a..?z, ?A..?Z, ?0..?9, ?-, ?_, ?:, ?@, ?., ?#], min_length: 1, max_length: 8) + end + + def attribute do + gen all( + name <- attribute_name(), + quotation <- member_of(["\"", "'", ""]), + value <- string(:alphanumeric, max_length: 6), + valued? <- boolean() + ) do + if valued?, do: name <> "=" <> quotation <> value <> quotation, else: name + end + end + + def attributes do + gen all(attributes <- list_of(attribute(), max_length: 3)) do + Enum.map_join(attributes, &(" " <> &1)) + end + end + + def start_tag do + gen all( + name <- tag_name(), + attributes <- attributes(), + closing <- member_of([">", "/>"]) + ) do + "<" <> name <> attributes <> closing + end + end + + def end_tag do + gen all(name <- tag_name()) do + " name <> ">" + end + end + + def comment do + gen all(body <- string([?a..?z, ?\s, ?<, ?>, ?!], max_length: 12)) do + "" + end + end + + def cdata do + gen all(body <- string([?a..?z, ?\s, ?<, ?>], max_length: 12)) do + " body <> "]]>" + end + end + + def doctype do + gen all( + keyword <- member_of(["DOCTYPE", "doctype", "DocType"]), + rest <- string([?a..?z, ?\s], max_length: 8) + ) do + " keyword <> " " <> rest <> ">" + end + end + + def processing_instruction do + gen all(body <- string([?a..?z, ?\s, ?=, ?"], max_length: 10)) do + " body <> ">" + end + end + + def entity do + gen all( + reference <- + one_of([ + string([?a..?z], min_length: 2, max_length: 5), + map(string([?0..?9], min_length: 1, max_length: 4), &("#" <> &1)) + ]) + ) do + "&" <> reference <> ";" + end + end + + def raw_element do + gen all( + name <- member_of(["script", "style"]), + body <- string([?a..?z, ?<, ?>, ?=, ?\s], max_length: 14) + ) do + "<" <> name <> ">" <> body <> " name <> ">" + end + end + + def text do + string([?a..?z, ?A..?Z, ?0..?9, ?\s, ?\n, ?&, ?>, ?., ?é, ?€], max_length: 12) + end + + def truncated(generator) do + map(generator, &String.slice(&1, 0, max(String.length(&1) - 1, 0))) + end + + def fragment do + one_of([ + start_tag(), + end_tag(), + comment(), + cdata(), + doctype(), + processing_instruction(), + entity(), + raw_element(), + text(), + truncated(start_tag()), + truncated(comment()), + truncated(cdata()), + truncated(raw_element()) + ]) + end + + def document do + gen all(fragments <- list_of(fragment(), max_length: 12)) do + Enum.join(fragments) + end + end +end From affcbb3967fe5de520ec8e8df9c6a4b2b7293b05 Mon Sep 17 00:00:00 2001 From: Javier Garea Date: Tue, 29 Sep 2026 19:07:00 +0200 Subject: [PATCH 04/12] fix: follow the spec for comments and declarations --- lib/makeup/lexers/html_lexer.ex | 59 +++++++------- .../html_lexer/html_lexer_tokenizer_test.exs | 77 +++++++++++++++++++ 2 files changed, 107 insertions(+), 29 deletions(-) diff --git a/lib/makeup/lexers/html_lexer.ex b/lib/makeup/lexers/html_lexer.ex index 6820e6b..a05bc5d 100644 --- a/lib/makeup/lexers/html_lexer.ex +++ b/lib/makeup/lexers/html_lexer.ex @@ -14,46 +14,47 @@ defmodule Makeup.Lexers.HTMLLexer do # Step #1: tokenize the input (into a list of tokens) ################################################################### - # Whitespaces - wspace = ascii_string([?\r, ?\s, ?\n, ?\t, ?\f], min: 1) - whitespace = token(wspace, :whitespace) + whitespace = + ascii_string([?\r, ?\s, ?\n, ?\t, ?\f], min: 1) + |> token(:whitespace) + + comment_close = choice([string("-->"), string("--!>")]) + empty_comment = choice([string(">"), string("->")]) - # Comment comment = string("")) |> utf8_string([], 1)) - |> optional(string("-->")) + |> choice([ + empty_comment, + repeat(lookahead_not(comment_close) |> utf8_string([], 1)) + |> optional(comment_close) + ]) |> token(:comment_multiline) - # CDATA section cdata = string(" repeat(lookahead_not(string("]]>")) |> utf8_string([], 1)) |> optional(string("]]>")) |> token(:comment_preproc) - # Processing instruction - processing_instruction = - string(" concat(anycase_string("DOCTYPE")) + |> repeat(utf8_string([not: ?>], 1)) + |> optional(string(">")) + |> token(:comment_preproc) + + bogus_comment = + choice([string(" repeat(utf8_string([not: ?>], 1)) |> optional(string(">")) |> token(:comment_preproc) - # Character reference - entity = + character_reference = string("&") |> concat(ascii_string([?a..?z, ?A..?Z, ?0..?9, ?#], min: 1)) |> concat(string(";")) |> token(:name_entity) - # Doctype - doctype = - string(" concat(anycase_string("DOCTYPE")) - |> repeat(utf8_string([not: ?>], 1)) - |> optional(string(">")) - |> token(:comment_preproc) - tag_name_chars = [?a..?z, ?A..?Z, ?0..?9, ?_, ?-, ?:, ?.] # A tag name cannot start with a digit. `.` and `:` are for HEEx function # components (`<.input>`) and slots (`<:inner_block>`). @@ -96,19 +97,21 @@ defmodule Makeup.Lexers.HTMLLexer do tag_attributes = repeat(choice([whitespace, attribute, stray_solidus])) - # End tag close_tag = token(string(" concat(token(tag_name, :name_tag)) |> optional(whitespace) |> concat(token(string(">"), :punctuation)) - # Start tag + tag_close = token(choice([string("/>"), string(">")]), :punctuation) + + # An attribute name may contain `<`. A tag with no `>` therefore scans to the + # end of the input. Without `eos` that scan fails and repeats from every `<`. open_tag = token(string("<"), :punctuation) |> concat(token(tag_name, :name_tag)) |> concat(tag_attributes) - |> concat(token(choice([string("/>"), string(">")]), :punctuation)) + |> concat(choice([tag_close, eos()])) # `<` and `>` are ordinary operators in JavaScript and CSS, so a script or # style body is one opaque token that ends only at its own close tag @@ -134,10 +137,8 @@ defmodule Makeup.Lexers.HTMLLexer do open |> optional(body) |> optional(close) end - # Text text = utf8_string([not: ?<, not: ?&], min: 1) |> token(:text) - # Unmatched any_char = utf8_string([], 1) |> token(:text) # Tag the tokens with the language name. @@ -147,21 +148,21 @@ defmodule Makeup.Lexers.HTMLLexer do {ttype, Map.put(meta, :language, :html), value} end - # First match wins, not longest, so: longest prefix first + # `choice` takes the first match, not the longest, so the order is significant. root_element_combinator = choice([ - # Markup declarations + # Comments and declarations comment, cdata, doctype, - processing_instruction, + bogus_comment, # Tags raw_element.("script"), raw_element.("style"), close_tag, open_tag, # Text - entity, + character_reference, text, any_char ]) diff --git a/test/makeup/html_lexer/html_lexer_tokenizer_test.exs b/test/makeup/html_lexer/html_lexer_tokenizer_test.exs index 1786ae0..659d722 100644 --- a/test/makeup/html_lexer/html_lexer_tokenizer_test.exs +++ b/test/makeup/html_lexer/html_lexer_tokenizer_test.exs @@ -54,6 +54,42 @@ defmodule HTMLLexerTokenizer do {:comment_multiline, %{}, ""} ] end + + test "an empty comment closes on >" do + assert lex("x") == [{:comment_multiline, %{}, ""}, {:text, %{}, "x"}] + end + + test "an empty comment closes on ->" do + assert lex("x") == [{:comment_multiline, %{}, ""}, {:text, %{}, "x"}] + end + + test " is a comment" do + assert lex("") == [{:comment_multiline, %{}, ""}] + end + + test "--!> closes the comment" do + assert lex("

y

") == [ + {:comment_multiline, %{}, ""}, + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "p"}, + {:punctuation, %{group_id: "group-1"}, ">"}, + {:text, %{}, "y"}, + {:punctuation, %{group_id: "group-2"}, ""} + ] + end + + test "--!> closes the comment before a later -->" do + assert lex(" b -->") == [ + {:comment_multiline, %{}, ""}, + {:text, %{}, " b -->"} + ] + end + + test "an unterminated comment runs to the end of the input" do + assert lex("" + gen all( + body <- string([?a..?z, ?\s, ?<, ?>, ?!, ?-], max_length: 12), + closing <- member_of(["-->", "--!>"]) + ) do + "", "", ""]) + end + def cdata do - gen all(body <- string([?a..?z, ?\s, ?<, ?>], max_length: 12)) do - " body <> "]]>" + gen all( + opening <- member_of([", ?]], max_length: 12) + ) do + opening <> body <> "]]>" end end @@ -69,13 +92,16 @@ defmodule HTMLGenerators do end end - def processing_instruction do - gen all(body <- string([?a..?z, ?\s, ?=, ?"], max_length: 10)) do - " body <> ">" + def bogus_comment do + gen all( + opening <- member_of([" body <> ">" end end - def entity do + def character_reference do gen all( reference <- one_of([ @@ -89,17 +115,19 @@ defmodule HTMLGenerators do def raw_element do gen all( - name <- member_of(["script", "style"]), - body <- string([?a..?z, ?<, ?>, ?=, ?\s], max_length: 14) + name <- member_of(["script", "style", "SCRIPT", "Style"]), + attributes <- attributes(), + body <- string([?a..?z, ?<, ?>, ?=, ?/, ?!, ?\s], max_length: 14) ) do - "<" <> name <> ">" <> body <> " name <> ">" + "<" <> name <> attributes <> ">" <> body <> " name <> ">" end end def text do - string([?a..?z, ?A..?Z, ?0..?9, ?\s, ?\n, ?&, ?>, ?., ?é, ?€], max_length: 12) + string([?a..?z, ?A..?Z, ?0..?9, ?\s, ?\n, ?&, ?<, ?>, ?., ?-, ?!, ?é, ?€], max_length: 12) end + # Drops the last character, so `` becomes ` +

Hello world! This is HTML5 Boilerplate.

+ + + + + diff --git a/bench/run.exs b/bench/run.exs new file mode 100644 index 0000000..327c344 --- /dev/null +++ b/bench/run.exs @@ -0,0 +1,35 @@ +# Adapted from makeup_elixir's benchmarks/main.exs. +alias Makeup.Formatters.HTML.HTMLFormatter +alias Makeup.Lexers.HTMLLexer + +# HTML5 Boilerplate's index.html, with the body repeated to a size worth +# measuring. Repeating the whole file would give a document with 100 doctypes. +# See bench/data/LICENSE for its copyright notice. +source_path = Path.join(__DIR__, "data/index.html") + +[doctype, body] = source_path |> File.read!() |> String.split("\n", parts: 2) +code = doctype <> "\n" <> String.duplicate(body, 100) +tokens = HTMLLexer.lex(code) + +Benchee.run( + %{ + "Lexer performance" => fn -> + HTMLLexer.lex(code) + end, + "Formatter performance" => fn -> + HTMLFormatter.format_as_binary(tokens) + end, + "Lexer + Formatter" => fn -> + code + |> HTMLLexer.lex() + |> HTMLFormatter.format_as_binary() + end, + "Lexer compilation time" => fn -> + Kernel.ParallelCompiler.compile(["lib/makeup/lexers/html_lexer.ex"]) + end + }, + memory_time: 1, + formatters: [ + {Benchee.Formatters.Console, comparison: false} + ] +) diff --git a/mix.exs b/mix.exs index 35e2738..64d73fe 100644 --- a/mix.exs +++ b/mix.exs @@ -56,7 +56,8 @@ defmodule MakeupHTML.Mixfile do ref: "6773349d54f55fd8a408c53a50108c0f41bff271", override: true}, {:stream_data, "~> 1.0", only: :test}, - {:ex_doc, "~> 0.24", only: :dev, runtime: false} + {:ex_doc, "~> 0.24", only: :dev, runtime: false}, + {:benchee, "~> 1.0", only: :dev} ] end end diff --git a/mix.lock b/mix.lock index 062b4c7..a82d92a 100644 --- a/mix.lock +++ b/mix.lock @@ -1,9 +1,12 @@ %{ + "benchee": {:hex, :benchee, "1.5.1", "b95cbc36c4b98969a5c592a246e171041eb683c56bad1cb4f49a3b081ba66087", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a539301f8dfd4efc5c5123bfb9d47ebde20092a863a5b5b16c2a60d2243dfce7"}, + "deep_merge": {:hex, :deep_merge, "1.0.2", "476aa7ea61c54de96220051b998d893869069094da65b96101aebf79416f8a1e", [:mix], [], "hexpm", "737a53cdc9758fedbb608bdc213969e65729466c4ef3cd8e8726d0335dff116c"}, "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, "ex_doc": {:hex, :ex_doc, "0.37.0", "970f92b39e62c460aa8a367508e938f5e4da6e2ff3eaed3f8530b25870f45471", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "b0ee7f17373948e0cf471e59c3a0ee42f3bd1171c67d91eb3626456ef9c6202c"}, "makeup": {:git, "https://github.com/elixir-makeup/makeup.git", "6773349d54f55fd8a408c53a50108c0f41bff271", [ref: "6773349d54f55fd8a408c53a50108c0f41bff271"]}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "statistex": {:hex, :statistex, "1.1.1", "73612aa7f79e53c30569be065fd121e380f1cf57bc4c2da5b41be9246da18df9", [:mix], [], "hexpm", "310c4b49b34adf683de3103639006bed233ab54c08a4add65a531448e653857c"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, } From 2e9611b3f467570b43b647f70b1a0f8f57982f0a Mon Sep 17 00:00:00 2001 From: Javier Garea Date: Fri, 2 Oct 2026 20:10:46 +0200 Subject: [PATCH 09/12] Revert "chore: add benchmarks" This reverts commit 7eeb910d2d779b1807ee78ece77f582583f723c4. --- bench/data/LICENSE | 19 ------------------- bench/data/index.html | 33 --------------------------------- bench/run.exs | 35 ----------------------------------- mix.exs | 3 +-- mix.lock | 3 --- 5 files changed, 1 insertion(+), 92 deletions(-) delete mode 100644 bench/data/LICENSE delete mode 100644 bench/data/index.html delete mode 100644 bench/run.exs diff --git a/bench/data/LICENSE b/bench/data/LICENSE deleted file mode 100644 index 294e91d..0000000 --- a/bench/data/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) HTML5 Boilerplate - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/bench/data/index.html b/bench/data/index.html deleted file mode 100644 index 1069d69..0000000 --- a/bench/data/index.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Hello world! This is HTML5 Boilerplate.

- - - - - diff --git a/bench/run.exs b/bench/run.exs deleted file mode 100644 index 327c344..0000000 --- a/bench/run.exs +++ /dev/null @@ -1,35 +0,0 @@ -# Adapted from makeup_elixir's benchmarks/main.exs. -alias Makeup.Formatters.HTML.HTMLFormatter -alias Makeup.Lexers.HTMLLexer - -# HTML5 Boilerplate's index.html, with the body repeated to a size worth -# measuring. Repeating the whole file would give a document with 100 doctypes. -# See bench/data/LICENSE for its copyright notice. -source_path = Path.join(__DIR__, "data/index.html") - -[doctype, body] = source_path |> File.read!() |> String.split("\n", parts: 2) -code = doctype <> "\n" <> String.duplicate(body, 100) -tokens = HTMLLexer.lex(code) - -Benchee.run( - %{ - "Lexer performance" => fn -> - HTMLLexer.lex(code) - end, - "Formatter performance" => fn -> - HTMLFormatter.format_as_binary(tokens) - end, - "Lexer + Formatter" => fn -> - code - |> HTMLLexer.lex() - |> HTMLFormatter.format_as_binary() - end, - "Lexer compilation time" => fn -> - Kernel.ParallelCompiler.compile(["lib/makeup/lexers/html_lexer.ex"]) - end - }, - memory_time: 1, - formatters: [ - {Benchee.Formatters.Console, comparison: false} - ] -) diff --git a/mix.exs b/mix.exs index 64d73fe..35e2738 100644 --- a/mix.exs +++ b/mix.exs @@ -56,8 +56,7 @@ defmodule MakeupHTML.Mixfile do ref: "6773349d54f55fd8a408c53a50108c0f41bff271", override: true}, {:stream_data, "~> 1.0", only: :test}, - {:ex_doc, "~> 0.24", only: :dev, runtime: false}, - {:benchee, "~> 1.0", only: :dev} + {:ex_doc, "~> 0.24", only: :dev, runtime: false} ] end end diff --git a/mix.lock b/mix.lock index a82d92a..062b4c7 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,9 @@ %{ - "benchee": {:hex, :benchee, "1.5.1", "b95cbc36c4b98969a5c592a246e171041eb683c56bad1cb4f49a3b081ba66087", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a539301f8dfd4efc5c5123bfb9d47ebde20092a863a5b5b16c2a60d2243dfce7"}, - "deep_merge": {:hex, :deep_merge, "1.0.2", "476aa7ea61c54de96220051b998d893869069094da65b96101aebf79416f8a1e", [:mix], [], "hexpm", "737a53cdc9758fedbb608bdc213969e65729466c4ef3cd8e8726d0335dff116c"}, "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, "ex_doc": {:hex, :ex_doc, "0.37.0", "970f92b39e62c460aa8a367508e938f5e4da6e2ff3eaed3f8530b25870f45471", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "b0ee7f17373948e0cf471e59c3a0ee42f3bd1171c67d91eb3626456ef9c6202c"}, "makeup": {:git, "https://github.com/elixir-makeup/makeup.git", "6773349d54f55fd8a408c53a50108c0f41bff271", [ref: "6773349d54f55fd8a408c53a50108c0f41bff271"]}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "statistex": {:hex, :statistex, "1.1.1", "73612aa7f79e53c30569be065fd121e380f1cf57bc4c2da5b41be9246da18df9", [:mix], [], "hexpm", "310c4b49b34adf683de3103639006bed233ab54c08a4add65a531448e653857c"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, } From 4fd99b9d177038af2bd617ceb97aadfcbac77968 Mon Sep 17 00:00:00 2001 From: Javier Garea Date: Fri, 2 Oct 2026 20:54:52 +0200 Subject: [PATCH 10/12] perf: do not export the root combinators --- lib/makeup/lexers/html_lexer.ex | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/makeup/lexers/html_lexer.ex b/lib/makeup/lexers/html_lexer.ex index 0280621..0fe7516 100644 --- a/lib/makeup/lexers/html_lexer.ex +++ b/lib/makeup/lexers/html_lexer.ex @@ -172,16 +172,14 @@ defmodule Makeup.Lexers.HTMLLexer do defparsec( :root_element, root_element_combinator |> map({__MODULE__, :__as_html_language__, []}), - inline: @inline, - export_combinator: true + inline: @inline ) # @impl Makeup.Lexer defparsec( :root, repeat(parsec(:root_element)), - inline: @inline, - export_combinator: true + inline: @inline ) ################################################################### From e274e7f7f9fd1cf09b67356646bde3567da4fb42 Mon Sep 17 00:00:00 2001 From: Javier Garea Date: Sat, 3 Oct 2026 22:35:21 +0200 Subject: [PATCH 11/12] build: require makeup 1.2.3 --- mix.exs | 7 +------ mix.lock | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/mix.exs b/mix.exs index 35e2738..1198e99 100644 --- a/mix.exs +++ b/mix.exs @@ -49,12 +49,7 @@ defmodule MakeupHTML.Mixfile do # Run "mix help deps" to learn about dependencies. defp deps do [ - # Pinned for the formatter fix in elixir-makeup/makeup#77. - # Back to `{:makeup, "~> 1.2"}` once it is released, hex rejects git deps. - {:makeup, - github: "elixir-makeup/makeup", - ref: "6773349d54f55fd8a408c53a50108c0f41bff271", - override: true}, + {:makeup, "~> 1.2.3 or ~> 1.3"}, {:stream_data, "~> 1.0", only: :test}, {:ex_doc, "~> 0.24", only: :dev, runtime: false} ] diff --git a/mix.lock b/mix.lock index 062b4c7..eace716 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, "ex_doc": {:hex, :ex_doc, "0.37.0", "970f92b39e62c460aa8a367508e938f5e4da6e2ff3eaed3f8530b25870f45471", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "b0ee7f17373948e0cf471e59c3a0ee42f3bd1171c67d91eb3626456ef9c6202c"}, - "makeup": {:git, "https://github.com/elixir-makeup/makeup.git", "6773349d54f55fd8a408c53a50108c0f41bff271", [ref: "6773349d54f55fd8a408c53a50108c0f41bff271"]}, + "makeup": {:hex, :makeup, "1.2.3", "c3ef0ff83acf305384acba0f70bff87d8732502baa1eb3eb081d8c154fbe18f5", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "15970751b7021c2bcd7ed56a54b3def23affd3969fb7c214dffdf28e97ce6f76"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, From 6bdc60b156239f8d0b953f71b6bc1ef404fea499 Mon Sep 17 00:00:00 2001 From: Javier Garea Date: Sat, 3 Oct 2026 22:47:34 +0200 Subject: [PATCH 12/12] test: pin quoted attribute values to a single token --- test/makeup/html_lexer/html_lexer_tokenizer_test.exs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/makeup/html_lexer/html_lexer_tokenizer_test.exs b/test/makeup/html_lexer/html_lexer_tokenizer_test.exs index 8255f67..221a760 100644 --- a/test/makeup/html_lexer/html_lexer_tokenizer_test.exs +++ b/test/makeup/html_lexer/html_lexer_tokenizer_test.exs @@ -268,6 +268,18 @@ defmodule HTMLLexerTokenizer do {:punctuation, %{group_id: "group-1"}, ">"} ] end + + test "
" do + assert lex("
") == [ + {:punctuation, %{group_id: "group-1"}, "<"}, + {:name_tag, %{}, "div"}, + {:whitespace, %{}, " "}, + {:name_attribute, %{}, "class"}, + {:operator, %{}, "="}, + {:string, %{}, "\"flex gap-2 mt-4\""}, + {:punctuation, %{group_id: "group-1"}, ">"} + ] + end end ###################################################################