From 75b830a78917262b1e136d22e5259ffdc63decd8 Mon Sep 17 00:00:00 2001 From: Brian Cardarella Date: Wed, 23 Oct 2024 02:10:33 -0400 Subject: [PATCH 1/6] First implementation of LVN template parser --- lib/live_view_native/template/parser.ex | 302 ++++++++++++++++++ .../live_view_native/template/parser_test.exs | 196 ++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 lib/live_view_native/template/parser.ex create mode 100644 test/live_view_native/template/parser_test.exs diff --git a/lib/live_view_native/template/parser.ex b/lib/live_view_native/template/parser.ex new file mode 100644 index 0000000..87d9011 --- /dev/null +++ b/lib/live_view_native/template/parser.ex @@ -0,0 +1,302 @@ +defmodule LiveViewNative.Template.Parser do + @first_chars ~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + @chars ~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" + @whitespace ~c"\s\t\n\r" + + def parse_document(document) do + parse(document, [line: 1, column: 1], []) + |> case do + {:ok, {nodes, _cursor}} -> {:ok, nodes} + error -> error + end + end + + def parse_document!(document) do + case parse_document(document) do + {:ok, {nodes, _cursor}} -> nodes + {:error, message, _range} -> raise message + end + end + + defp parse(<<>>, cursor, nodes), + do: {:ok, {Enum.reverse(nodes), cursor}} + + # this next two functions are special escape function that are only used to detect + # the start of an end tag and eject from parsing children + # they does not conform to the same return type as other functions of this name + defp parse(<<"> = document, cursor, nodes) do + {:ok, {document, nodes, cursor}} + end + + defp parse(<<"/>", _document::binary>> = document, cursor, nodes) do + {:ok, {document, nodes, cursor}} + end + + defp parse(<<"", document::binary>>, cursor, buffer) do + cursor = incr_column(cursor, 3) + + comment = + buffer + |> Enum.reverse() + |> List.to_string() + + {:ok, {document, [comment: comment], cursor}} + end + + defp parse_comment_node(<>, cursor, buffer) do + parse_comment_node(document, cursor, [char | buffer]) + end + + defp parse_node(document, cursor = start_cursor) do + with {:ok, {document, tag_name, cursor}} <- parse_tag_name(document, cursor, []), + {:ok, {document, attributes, cursor}} <- parse_attributes(document, cursor, []), + {:ok, {document, cursor}} <- parse_tag_close(document, cursor, start_cursor), + {:ok, {document, children, cursor}} <- parse_children(document, cursor), + {:ok, {document, cursor}} <- parse_end_tag(document, cursor, [], tag_name, start_cursor) do + {:ok, {document, {tag_name, attributes, children}, cursor}} + else + {:error, message, range} -> {:error, message, range} + end + end + + defp parse_tag_name(<<>>, cursor, _buffer) do + {:error, "unexpected end of file while parsing attribute key", [start: cursor, end: cursor]} + end + + defp parse_tag_name(<>, cursor, []) when char in @first_chars do + cursor = incr_column(cursor) + parse_tag_name(document, cursor, [char]) + end + + defp parse_tag_name(<>, cursor, buffer) when char in @chars do + cursor = incr_column(cursor) + parse_tag_name(document, cursor, [char | buffer]) + end + + defp parse_tag_name(<<"/>", _document::binary>> = document, cursor, buffer), + do: return_tag_name(document, buffer, cursor) + defp parse_tag_name(<<">", _document::binary>> = document, cursor, buffer), + do: return_tag_name(document, buffer, cursor) + defp parse_tag_name(<> = document, cursor, buffer) when char in @whitespace do + return_tag_name(document, buffer, cursor) + end + + defp parse_tag_name(<>, cursor, _buffer) do + {:error, "invalid character in tag name: #{[char]}", [start: cursor, end: cursor]} + end + + defp return_tag_name(document, buffer, cursor) do + tag_name = + buffer + |> Enum.reverse() + |> List.to_string() + + {:ok, {document, tag_name, cursor}} + end + + defp parse_attributes(<>, cursor, buffer) when char in @whitespace do + cursor = move_cursor(cursor, char) + parse_attributes(document, cursor, buffer) + end + + defp parse_attributes(<<"/>", _document::binary>> = document, cursor, buffer) do + attributes = Enum.reverse(buffer) + {:ok, {document, attributes, cursor}} + end + + defp parse_attributes(<<">", _document::binary>> = document, cursor, buffer) do + attributes = Enum.reverse(buffer) + {:ok, {document, attributes, cursor}} + end + + defp parse_attributes(document, cursor, buffer) do + case parse_attribute(document, cursor) do + {:ok, {document, attribute, cursor}} -> parse_attributes(document, cursor, [attribute | buffer]) + error -> error + end + end + + defp parse_attribute(document, cursor) do + with {:ok, {document, key, cursor}} <- parse_attribute_key(document, cursor, []), + {:ok, {document, value, cursor}} <- parse_attribute_value(document, cursor, []) do + {:ok, {document, {key, value}, cursor}} + else + error -> error + end + end + + defp parse_attribute_key(<>, cursor, buffer) when char in @whitespace do + cursor = move_cursor(cursor, char) + parse_attribute_key(document, cursor, buffer) + end + + defp parse_attribute_key(<>, cursor, []) when char in @first_chars do + parse_attribute_key(document, incr_column(cursor), [char]) + end + + defp parse_attribute_key(<>, cursor, key_buffer) when char in @chars do + parse_attribute_key(document, incr_column(cursor), [char | key_buffer]) + end + + defp parse_attribute_key(<<"=", document::binary>>, cursor, key_buffer) do + key = + key_buffer + |> Enum.reverse() + |> List.to_string() + + {document, cursor} = drain_whitespace(document, incr_column(cursor)) + + {:ok, {document, key, cursor}} + end + + defp parse_attribute_key(<<>>, cursor, _buffer) do + {:error, "unexpected end of file while parsing attribute key", [start: cursor, end: cursor]} + end + + defp parse_attribute_key(<>, cursor, _buffer) do + {:error, "invalid character in attribute key: #{[char]}", [start: cursor, end: cursor]} + end + + defp parse_attribute_value(<<>>, cursor, _buffer) do + {:error, "unexpected end of file while parsing attribute value", [start: cursor, end: cursor]} + end + + defp parse_attribute_value(<<"\"\"", document::binary>>, cursor, []) do + cursor = incr_column(cursor, 2) + {:ok, {document, "", cursor}} + end + + defp parse_attribute_value(<<"\"", char, document::binary>>, cursor, []) do + cursor = + cursor + |> incr_column() + |> move_cursor(char) + + parse_attribute_value(document, cursor, [char]) + end + + defp parse_attribute_value(<<"\"", document::binary>>, cursor, buffer) do + value = + buffer + |> Enum.reverse() + |> List.to_string() + + {:ok, {document, value, incr_column(cursor)}} + end + + defp parse_attribute_value(_document, cursor, []) do + {:error, "invalid value format for attribute", [start: cursor, end: cursor]} + end + + defp parse_attribute_value(<>, cursor, buffer) do + cursor = move_cursor(cursor, char) + parse_attribute_value(document, cursor, [char | buffer]) + end + + defp parse_tag_close(<<">", document::binary>>, cursor, _start_cursor) do + {:ok, drain_whitespace(document, incr_column(cursor))} + end + + defp parse_tag_close(<<"/>", _document::binary>> = document, cursor, _start_cursor) do + {:ok, {document, cursor}} + end + + defp parse_tag_close(_document, cursor, start_cursor) do + {:error, "tag entity not closed", [start: start_cursor, end: cursor]} + end + + defp parse_children(document, cursor) do + case parse(document, cursor, []) do + {:ok, {"", _nodes, cursor}} -> {:error, "unexpected end of file", [start: cursor, end: cursor]} + result -> result + end + end + + defp parse_end_tag(<<">, cursor, buffer, tag_name, start_cursor) do + cursor = incr_column(cursor, 2) + {document, cursor} = drain_whitespace(document, cursor) + parse_end_tag(document, cursor, buffer, tag_name, start_cursor) + end + + defp parse_end_tag(<>, cursor, [], tag_name, start_cursor) when char in @first_chars do + cursor = incr_column(cursor) + parse_end_tag(document, cursor, [char], tag_name, start_cursor) + end + + defp parse_end_tag(<>, cursor, buffer, tag_name, start_cursor) when char in @chars do + cursor = incr_column(cursor) + parse_end_tag(document, cursor, [char | buffer], tag_name, start_cursor) + end + + defp parse_end_tag(document, cursor, [], _tag_name, _start_cursor) do + case document do + <<">", document::binary>> -> {:ok, {document, incr_column(cursor)}} + <<"/>", document::binary>> -> {:ok, {document, incr_column(cursor, 2)}} + _document -> {:error, "invalid character for end tag", [start: cursor, end: cursor]} + end + end + + defp parse_end_tag(document, end_cursor, buffer, tag_name, start_cursor) do + {document, cursor} = drain_whitespace(document, end_cursor) + + closing_tag_name = + buffer + |> Enum.reverse() + |> List.to_string() + + if tag_name != closing_tag_name do + {:error, "starting tagname does not match closing tagname", [start: start_cursor, end: end_cursor]} + else + parse_end_tag(document, cursor, [], tag_name, start_cursor) + end + end + + defp drain_whitespace(<>, cursor) when char in @whitespace do + drain_whitespace(document, move_cursor(cursor, char)) + end + + defp drain_whitespace(document, cursor), + do: {document, cursor} + + defp move_cursor(cursor, char) when char in [?\n] do + incr_line(cursor) + end + defp move_cursor(cursor, _char), + do: incr_column(cursor) + + defp incr_column([line: line, column: column], count \\ 1), + do: [line: line, column: column + count] + + defp incr_line([line: line, column: _column], count \\ 1) do + [line: line + count, column: 1] + end +end diff --git a/test/live_view_native/template/parser_test.exs b/test/live_view_native/template/parser_test.exs new file mode 100644 index 0000000..2f7cc8e --- /dev/null +++ b/test/live_view_native/template/parser_test.exs @@ -0,0 +1,196 @@ +defmodule LiveViewNative.Template.ParserTest do + use ExUnit.Case, async: false + import LiveViewNative.Template.Parser + + test "will parse a tag" do + {:ok, nodes} = """ + + + + + """ + |> parse_document() + + assert nodes == [ + {"FooBar", [], []}, + {"FooBar", [], []}, + {"FooBar", [], []} + ] + end + + test "will parse a self-closing tags" do + {:ok, nodes} = """ + + + """ + |> parse_document() + + assert nodes == [ + {"FooBar", [], []}, + {"FooBar", [], []} + ] + end + + test "will parse attributes" do + {:ok, nodes} = """ + + + + """ + |> parse_document() + + assert nodes == [ + {"FooBar", [{"a", "123"}, {"b", "321"}, {"c", "789"}], []}, + {"FooBar", [{"a-b", "456"}], []}, + {"FooBar", [{"a", "987"}, {"b-c", "654"}], []} + ] + end + + test "will parse children" do + {:ok, nodes} = """ + + + """ + |> parse_document() + + assert nodes == [ + {"Foo", [], [ + {"Bar", [], [ + {"Baz", [], []} + ]} + ]}, + {"Foo", [], [ + {"Bar", [], []} + ]} + ] + + end + + test "can parse comments" do + {:ok, nodes} = """ + + + + + + + """ + |> parse_document() + + assert nodes == [ + {"FooBar", [], []}, + [comment: " \n\n"], + {"FooBar", [], [ + [comment: " "] + ]} + ] + end + + test "empty" do + {:ok, nodes} = parse_document("") + + assert nodes == [] + end + + describe "parsing errors" do + test "eof within a comment" do + {:error, _message, [start: start_pos, end: end_pos]} = "", document::binary>>, cursor, buffer) do + defp parse_comment_node(<<"-->", document::binary>>, cursor, buffer, _args) do cursor = incr_column(cursor, 3) comment = @@ -100,49 +100,49 @@ defmodule LiveViewNative.Template.Parser do {:ok, {document, [comment: comment], cursor}} end - defp parse_comment_node(<>, cursor, buffer) do - parse_comment_node(document, cursor, [char | buffer]) + defp parse_comment_node(<>, cursor, buffer, args) do + parse_comment_node(document, cursor, [char | buffer], args) end - defp parse_node(document, cursor = start_cursor) do - with {:ok, {document, tag_name, cursor}} <- parse_tag_name(document, cursor, []), - {:ok, {document, attributes, cursor}} <- parse_attributes(document, cursor, []), - {:ok, {document, cursor}} <- parse_tag_close(document, cursor, start_cursor), - {:ok, {document, children, cursor}} <- parse_children(document, cursor), - {:ok, {document, cursor}} <- parse_end_tag(document, cursor, [], tag_name, start_cursor) do + defp parse_node(document, cursor = start_cursor, args) do + with {:ok, {document, tag_name, cursor}} <- parse_tag_name(document, cursor, [], args), + {:ok, {document, attributes, cursor}} <- parse_attributes(document, cursor, [], args), + {:ok, {document, cursor}} <- parse_tag_close(document, cursor, start_cursor, args), + {:ok, {document, children, cursor}} <- parse_children(document, cursor, args), + {:ok, {document, cursor}} <- parse_end_tag(document, cursor, [], tag_name, start_cursor, args) do {:ok, {document, {tag_name, attributes, children}, cursor}} else {:error, message, range} -> {:error, message, range} end end - defp parse_tag_name(<<>>, cursor, _buffer) do + defp parse_tag_name(<<>>, cursor, _buffer, _args) do {:error, "unexpected end of file while parsing attribute key", [start: cursor, end: cursor]} end - defp parse_tag_name(<>, cursor, []) when char in @first_chars do + defp parse_tag_name(<>, cursor, [], args) when char in @first_chars do cursor = incr_column(cursor) - parse_tag_name(document, cursor, [char]) + parse_tag_name(document, cursor, [char], args) end - defp parse_tag_name(<>, cursor, buffer) when char in @chars do + defp parse_tag_name(<>, cursor, buffer, args) when char in @chars do cursor = incr_column(cursor) - parse_tag_name(document, cursor, [char | buffer]) + parse_tag_name(document, cursor, [char | buffer], args) end - defp parse_tag_name(<<"/>", _document::binary>> = document, cursor, buffer), - do: return_tag_name(document, buffer, cursor) - defp parse_tag_name(<<">", _document::binary>> = document, cursor, buffer), - do: return_tag_name(document, buffer, cursor) - defp parse_tag_name(<> = document, cursor, buffer) when char in @whitespace do - return_tag_name(document, buffer, cursor) + defp parse_tag_name(<<"/>", _document::binary>> = document, cursor, buffer, args), + do: return_tag_name(document, buffer, cursor, args) + defp parse_tag_name(<<">", _document::binary>> = document, cursor, buffer, args), + do: return_tag_name(document, buffer, cursor, args) + defp parse_tag_name(<> = document, cursor, buffer, args) when char in @whitespace do + return_tag_name(document, buffer, cursor, args) end - defp parse_tag_name(<>, cursor, _buffer) do + defp parse_tag_name(<>, cursor, _buffer, _args) do {:error, "invalid character in tag name: #{[char]}", [start: cursor, end: cursor]} end - defp return_tag_name(document, buffer, cursor) do + defp return_tag_name(document, buffer, cursor, _args) do tag_name = buffer |> Enum.reverse() @@ -151,51 +151,58 @@ defmodule LiveViewNative.Template.Parser do {:ok, {document, tag_name, cursor}} end - defp parse_attributes(<>, cursor, buffer) when char in @whitespace do + defp parse_attributes(<>, cursor, buffer, args) when char in @whitespace do cursor = move_cursor(cursor, char) - parse_attributes(document, cursor, buffer) + parse_attributes(document, cursor, buffer, args) end - defp parse_attributes(<<"/>", _document::binary>> = document, cursor, buffer) do - attributes = Enum.reverse(buffer) - {:ok, {document, attributes, cursor}} + defp parse_attributes(<<"/>", _document::binary>> = document, cursor, buffer, args) do + return_attributes(document, buffer, cursor, args) end - defp parse_attributes(<<">", _document::binary>> = document, cursor, buffer) do - attributes = Enum.reverse(buffer) - {:ok, {document, attributes, cursor}} + defp parse_attributes(<<">", _document::binary>> = document, cursor, buffer, args) do + return_attributes(document, buffer, cursor, args) end - defp parse_attributes(document, cursor, buffer) do - case parse_attribute(document, cursor) do - {:ok, {document, attribute, cursor}} -> parse_attributes(document, cursor, [attribute | buffer]) + defp parse_attributes(document, cursor, buffer, args) do + case parse_attribute(document, cursor, args) do + {:ok, {document, attribute, cursor}} -> parse_attributes(document, cursor, [attribute | buffer], args) error -> error end end - defp parse_attribute(document, cursor) do - with {:ok, {document, key, cursor}} <- parse_attribute_key(document, cursor, []), - {:ok, {document, value, cursor}} <- parse_attribute_value(document, cursor, []) do + defp parse_attribute(document, cursor, args) do + with {:ok, {document, key, cursor}} <- parse_attribute_key(document, cursor, [], args), + {:ok, {document, value, cursor}} <- parse_attribute_value(document, cursor, [], args) do {:ok, {document, {key, value}, cursor}} else error -> error end end - defp parse_attribute_key(<>, cursor, buffer) when char in @whitespace do + defp return_attributes(document, buffer, cursor, args) do + attributes = if Keyword.get(args, :attributes_as_maps, false) do + Enum.into(buffer, %{}) + else + Enum.reverse(buffer) + end + {:ok, {document, attributes, cursor}} + end + + defp parse_attribute_key(<>, cursor, buffer, args) when char in @whitespace do cursor = move_cursor(cursor, char) - parse_attribute_key(document, cursor, buffer) + parse_attribute_key(document, cursor, buffer, args) end - defp parse_attribute_key(<>, cursor, []) when char in @first_chars do - parse_attribute_key(document, incr_column(cursor), [char]) + defp parse_attribute_key(<>, cursor, [], args) when char in @first_chars do + parse_attribute_key(document, incr_column(cursor), [char], args) end - defp parse_attribute_key(<>, cursor, key_buffer) when char in @chars do - parse_attribute_key(document, incr_column(cursor), [char | key_buffer]) + defp parse_attribute_key(<>, cursor, key_buffer, args) when char in @chars do + parse_attribute_key(document, incr_column(cursor), [char | key_buffer], args) end - defp parse_attribute_key(<<"=", document::binary>>, cursor, key_buffer) do + defp parse_attribute_key(<<"=", document::binary>>, cursor, key_buffer, _args) do key = key_buffer |> Enum.reverse() @@ -206,33 +213,33 @@ defmodule LiveViewNative.Template.Parser do {:ok, {document, key, cursor}} end - defp parse_attribute_key(<<>>, cursor, _buffer) do + defp parse_attribute_key(<<>>, cursor, _buffer, _args) do {:error, "unexpected end of file while parsing attribute key", [start: cursor, end: cursor]} end - defp parse_attribute_key(<>, cursor, _buffer) do + defp parse_attribute_key(<>, cursor, _buffer, _args) do {:error, "invalid character in attribute key: #{[char]}", [start: cursor, end: cursor]} end - defp parse_attribute_value(<<>>, cursor, _buffer) do + defp parse_attribute_value(<<>>, cursor, _buffer, _args) do {:error, "unexpected end of file while parsing attribute value", [start: cursor, end: cursor]} end - defp parse_attribute_value(<<"\"\"", document::binary>>, cursor, []) do + defp parse_attribute_value(<<"\"\"", document::binary>>, cursor, [], _args) do cursor = incr_column(cursor, 2) {:ok, {document, "", cursor}} end - defp parse_attribute_value(<<"\"", char, document::binary>>, cursor, []) do + defp parse_attribute_value(<<"\"", char, document::binary>>, cursor, [], args) do cursor = cursor |> incr_column() |> move_cursor(char) - parse_attribute_value(document, cursor, [char]) + parse_attribute_value(document, cursor, [char], args) end - defp parse_attribute_value(<<"\"", document::binary>>, cursor, buffer) do + defp parse_attribute_value(<<"\"", document::binary>>, cursor, buffer, _args) do value = buffer |> Enum.reverse() @@ -241,51 +248,51 @@ defmodule LiveViewNative.Template.Parser do {:ok, {document, value, incr_column(cursor)}} end - defp parse_attribute_value(_document, cursor, []) do + defp parse_attribute_value(_document, cursor, [], _args) do {:error, "invalid value format for attribute", [start: cursor, end: cursor]} end - defp parse_attribute_value(<>, cursor, buffer) do + defp parse_attribute_value(<>, cursor, buffer, args) do cursor = move_cursor(cursor, char) - parse_attribute_value(document, cursor, [char | buffer]) + parse_attribute_value(document, cursor, [char | buffer], args) end - defp parse_tag_close(<<">", document::binary>>, cursor, _start_cursor) do + defp parse_tag_close(<<">", document::binary>>, cursor, _start_cursor, _args) do {:ok, {document, incr_column(cursor)}} end - defp parse_tag_close(<<"/>", _document::binary>> = document, cursor, _start_cursor) do + defp parse_tag_close(<<"/>", _document::binary>> = document, cursor, _start_cursor, _args) do {:ok, {document, cursor}} end - defp parse_tag_close(_document, cursor, start_cursor) do + defp parse_tag_close(_document, cursor, start_cursor, _args) do {:error, "tag entity not closed", [start: start_cursor, end: cursor]} end - defp parse_children(document, cursor) do - case parse(document, cursor, []) do + defp parse_children(document, cursor, args) do + case parse(document, cursor, [], args) do {:ok, {"", _nodes, cursor}} -> {:error, "unexpected end of file", [start: cursor, end: cursor]} result -> result end end - defp parse_end_tag(<<">, cursor, buffer, tag_name, start_cursor) do + defp parse_end_tag(<<">, cursor, buffer, tag_name, start_cursor, args) do cursor = incr_column(cursor, 2) {document, cursor} = drain_whitespace(document, cursor) - parse_end_tag(document, cursor, buffer, tag_name, start_cursor) + parse_end_tag(document, cursor, buffer, tag_name, start_cursor, args) end - defp parse_end_tag(<>, cursor, [], tag_name, start_cursor) when char in @first_chars do + defp parse_end_tag(<>, cursor, [], tag_name, start_cursor, args) when char in @first_chars do cursor = incr_column(cursor) - parse_end_tag(document, cursor, [char], tag_name, start_cursor) + parse_end_tag(document, cursor, [char], tag_name, start_cursor, args) end - defp parse_end_tag(<>, cursor, buffer, tag_name, start_cursor) when char in @chars do + defp parse_end_tag(<>, cursor, buffer, tag_name, start_cursor, args) when char in @chars do cursor = incr_column(cursor) - parse_end_tag(document, cursor, [char | buffer], tag_name, start_cursor) + parse_end_tag(document, cursor, [char | buffer], tag_name, start_cursor, args) end - defp parse_end_tag(document, cursor, [], _tag_name, _start_cursor) do + defp parse_end_tag(document, cursor, [], _tag_name, _start_cursor, _args) do case document do <<">", document::binary>> -> {:ok, {document, incr_column(cursor)}} <<"/>", document::binary>> -> {:ok, {document, incr_column(cursor, 2)}} @@ -293,7 +300,7 @@ defmodule LiveViewNative.Template.Parser do end end - defp parse_end_tag(document, end_cursor, buffer, tag_name, start_cursor) do + defp parse_end_tag(document, end_cursor, buffer, tag_name, start_cursor, args) do {document, cursor} = drain_whitespace(document, end_cursor) closing_tag_name = @@ -304,7 +311,7 @@ defmodule LiveViewNative.Template.Parser do if tag_name != closing_tag_name do {:error, "starting tagname does not match closing tagname", [start: start_cursor, end: end_cursor]} else - parse_end_tag(document, cursor, [], tag_name, start_cursor) + parse_end_tag(document, cursor, [], tag_name, start_cursor, args) end end diff --git a/test/live_view_native/template/parser_test.exs b/test/live_view_native/template/parser_test.exs index 5d82afc..7b9ae3e 100644 --- a/test/live_view_native/template/parser_test.exs +++ b/test/live_view_native/template/parser_test.exs @@ -49,6 +49,24 @@ defmodule LiveViewNative.Template.ParserTest do ] end + test "will parse attributes as a map" do + {:ok, nodes} = """ + + + + """ + |> parse_document(attributes_as_maps: true) + + assert nodes == [ + {"FooBar", %{"a" => "123", "b" => "321", "c" => "789"}, []}, + {"FooBar", %{"a-b" => "456"}, []}, + {"FooBar", %{"a" => "987", "b-c" => "654"}, []} + ] + end + test "will parse children" do {:ok, nodes} = """ From 4099bdf947c83726cd32a7db053c6de821ff2f37 Mon Sep 17 00:00:00 2001 From: Brian Cardarella Date: Thu, 24 Oct 2024 21:42:37 -0400 Subject: [PATCH 4/6] Refactor to rely on move_cursor --- lib/live_view_native/template/parser.ex | 54 +++++++++++-------------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/lib/live_view_native/template/parser.ex b/lib/live_view_native/template/parser.ex index f4e94d2..1e74968 100644 --- a/lib/live_view_native/template/parser.ex +++ b/lib/live_view_native/template/parser.ex @@ -32,7 +32,7 @@ defmodule LiveViewNative.Template.Parser do end defp parse(<<"", document::binary>>, cursor, buffer, _args) do - cursor = incr_column(cursor, 3) + cursor = move_cursor(cursor, ~c"-->") comment = buffer @@ -121,12 +121,12 @@ defmodule LiveViewNative.Template.Parser do end defp parse_tag_name(<>, cursor, [], args) when char in @first_chars do - cursor = incr_column(cursor) + cursor = move_cursor(cursor, char) parse_tag_name(document, cursor, [char], args) end defp parse_tag_name(<>, cursor, buffer, args) when char in @chars do - cursor = incr_column(cursor) + cursor = move_cursor(cursor, char) parse_tag_name(document, cursor, [char | buffer], args) end @@ -195,11 +195,11 @@ defmodule LiveViewNative.Template.Parser do end defp parse_attribute_key(<>, cursor, [], args) when char in @first_chars do - parse_attribute_key(document, incr_column(cursor), [char], args) + parse_attribute_key(document, move_cursor(cursor, char), [char], args) end defp parse_attribute_key(<>, cursor, key_buffer, args) when char in @chars do - parse_attribute_key(document, incr_column(cursor), [char | key_buffer], args) + parse_attribute_key(document, move_cursor(cursor, char), [char | key_buffer], args) end defp parse_attribute_key(<<"=", document::binary>>, cursor, key_buffer, _args) do @@ -208,7 +208,7 @@ defmodule LiveViewNative.Template.Parser do |> Enum.reverse() |> List.to_string() - {document, cursor} = drain_whitespace(document, incr_column(cursor)) + {document, cursor} = drain_whitespace(document, move_cursor(cursor, ?=)) {:ok, {document, key, cursor}} end @@ -226,16 +226,12 @@ defmodule LiveViewNative.Template.Parser do end defp parse_attribute_value(<<"\"\"", document::binary>>, cursor, [], _args) do - cursor = incr_column(cursor, 2) + cursor = move_cursor(cursor, ~c'""') {:ok, {document, "", cursor}} end defp parse_attribute_value(<<"\"", char, document::binary>>, cursor, [], args) do - cursor = - cursor - |> incr_column() - |> move_cursor(char) - + cursor = move_cursor(cursor, [?", char]) parse_attribute_value(document, cursor, [char], args) end @@ -245,7 +241,7 @@ defmodule LiveViewNative.Template.Parser do |> Enum.reverse() |> List.to_string() - {:ok, {document, value, incr_column(cursor)}} + {:ok, {document, value, move_cursor(cursor, ?")}} end defp parse_attribute_value(_document, cursor, [], _args) do @@ -258,7 +254,7 @@ defmodule LiveViewNative.Template.Parser do end defp parse_tag_close(<<">", document::binary>>, cursor, _start_cursor, _args) do - {:ok, {document, incr_column(cursor)}} + {:ok, {document, move_cursor(cursor, ?>)}} end defp parse_tag_close(<<"/>", _document::binary>> = document, cursor, _start_cursor, _args) do @@ -277,25 +273,25 @@ defmodule LiveViewNative.Template.Parser do end defp parse_end_tag(<<">, cursor, buffer, tag_name, start_cursor, args) do - cursor = incr_column(cursor, 2) + cursor = move_cursor(cursor, ~c">, cursor, [], tag_name, start_cursor, args) when char in @first_chars do - cursor = incr_column(cursor) + cursor = move_cursor(cursor, char) parse_end_tag(document, cursor, [char], tag_name, start_cursor, args) end defp parse_end_tag(<>, cursor, buffer, tag_name, start_cursor, args) when char in @chars do - cursor = incr_column(cursor) + cursor = move_cursor(cursor, char) parse_end_tag(document, cursor, [char | buffer], tag_name, start_cursor, args) end defp parse_end_tag(document, cursor, [], _tag_name, _start_cursor, _args) do case document do - <<">", document::binary>> -> {:ok, {document, incr_column(cursor)}} - <<"/>", document::binary>> -> {:ok, {document, incr_column(cursor, 2)}} + <<">", document::binary>> -> {:ok, {document, move_cursor(cursor, ?>)}} + <<"/>", document::binary>> -> {:ok, {document, move_cursor(cursor, ~c"/>")}} _document -> {:error, "invalid character for end tag", [start: cursor, end: cursor]} end end @@ -322,16 +318,12 @@ defmodule LiveViewNative.Template.Parser do defp drain_whitespace(document, cursor), do: {document, cursor} - defp move_cursor(cursor, char) when char in [?\n] do - incr_line(cursor) - end - defp move_cursor(cursor, _char), - do: incr_column(cursor) + defp move_cursor(cursor, chars) when is_list(chars), + do: Enum.reduce(chars, cursor, &move_cursor(&2, &1)) - defp incr_column([line: line, column: column], count \\ 1), - do: [line: line, column: column + count] + defp move_cursor(cursor, char) when char in [?\n], + do: [line: cursor[:line] + 1, column: 1] - defp incr_line([line: line, column: _column], count \\ 1) do - [line: line + count, column: 1] - end + defp move_cursor(cursor, _char), + do: [line: cursor[:line], column: cursor[:column] + 1] end From 9dcca0c616d2a5ccc21420ea1a84732935872915 Mon Sep 17 00:00:00 2001 From: Brian Cardarella Date: Fri, 25 Oct 2024 09:01:52 -0400 Subject: [PATCH 5/6] Documentation and exception testing --- CHANGELOG.md | 2 + lib/live_view_native/template/parse_error.ex | 44 +++++++ lib/live_view_native/template/parser.ex | 84 ++++++++++++- .../live_view_native/template/parser_test.exs | 110 +++++++++++------- 4 files changed, 199 insertions(+), 41 deletions(-) create mode 100644 lib/live_view_native/template/parse_error.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 46de98f..d24e8ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* LiveViewNative.Template.Parser - a new Elixir parser for the LVN template syntax + ### Changed * `LiveViewNative.Component` no longer imports `Phoenix.Component.to_form/2` diff --git a/lib/live_view_native/template/parse_error.ex b/lib/live_view_native/template/parse_error.ex new file mode 100644 index 0000000..2b6ea61 --- /dev/null +++ b/lib/live_view_native/template/parse_error.ex @@ -0,0 +1,44 @@ +defmodule LiveViewNative.Template.ParseError do + @moduledoc """ + + + """ + + defexception [:message] + + @impl true + def exception({document, message, [start: cursor, end: cursor]}) do + msg = """ + #{message} + + #{document_line(document, cursor)} + """ + + %__MODULE__{message: msg} + end + + def exception({document, message, [start: start_cursor, end: end_cursor]}) do + msg = """ + #{message} + + Start: + #{document_line(document, start_cursor)} + + End: + #{document_line(document, end_cursor)} + """ + + %__MODULE__{message: msg} + end + + defp document_line(document, [line: line, column: column]) do + doc_line = + document + |> String.split("\n") + |> Enum.at(line - 1) + + loc = "#{line}: " + + ~s|#{loc}#{doc_line}\n#{String.duplicate(" ", String.length(loc))}#{String.pad_leading("^", column, "-")}| + end +end diff --git a/lib/live_view_native/template/parser.ex b/lib/live_view_native/template/parser.ex index 1e74968..7baa5bd 100644 --- a/lib/live_view_native/template/parser.ex +++ b/lib/live_view_native/template/parser.ex @@ -1,8 +1,76 @@ defmodule LiveViewNative.Template.Parser do + @moduledoc ~S''' + Floki-compliant parser for LiveView Native template syntax + + iex> """ + ...> + ...> Hello + ...> world! + ...> + ...> """ + ...> |> LiveViewNative.Template.Parser.parse_document() + {:ok, [{"Group", [], [{"Text", [{"class", "bold"}], ["Hello"]}, {"Text", [], ["world!"]}]}]} + + You can pass this AST into Floki for querying: + + iex> """ + ...> + ...> Hello" + ...> world! + ...> + ...> """ + ...> |> LiveViewNative.Template.Parser.parse_document!() + ...> |> Floki.find("Text.bold") + [{"Text", [{"class", "bold"}], ["Hello"]}] + + ## Floki Integration + + Floki support passing parser in by option, this parser is compliant with that API: + + iex> """ + ...> + ...> Hello" + ...> world! + ...> + ...> """ + ...> |> Floki.parse_document!(html_parser: LiveViewNative.Template.Parser) + ...> |> Floki.find("Text.bold") + [{"Text", [{"class", "bold"}], ["Hello"]}] + + Or you can configure as the default: + + ```elixir + config :floki, :html_parser, LiveViewNative.Tempalte.Parser + ``` + ''' + + alias LiveViewNative.Template.ParseError + @first_chars ~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" @chars ~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" @whitespace ~c"\s\t\n\r" + @doc """ + Parses an LVN document from a string. + + This is the main function to get a tree from a LVN string. + + ## Options + + * `:attributes_as_maps` - Change the behaviour of the parser to return the attributes + as maps, instead of a list of `{"key", "value"}`. Default to `false`. + + ## Examples + + iex> LiveViewNative.Template.Parser.parse_document("hello") + {:ok, [{"Group", [], [{"Text", [], []}, {"Text", [], ["hello"]}]}]} + + iex> LiveViewNative.Template.Parser.parse_document( + ...> ~S(hello), + ...> attributes_as_maps: true + ...>) + {:ok, [{"Group", %{}, [{"Text", %{}, []}, {"Text", %{"class" => "main"}, ["hello"]}]}]} + """ def parse_document(document, args \\ []) do parse(document, [line: 1, column: 1], [], args) |> case do @@ -11,10 +79,22 @@ defmodule LiveViewNative.Template.Parser do end end + @doc """ + Parses a LVN Document from a string. + + Similar to `parse_document/1`, but raises `LiveViewNative.Template.ParseError` if there was an + error parsing the document. + + ## Example + + iex> LiveViewNative.Template.Parser.parse_document!("hello") + [{"Group", [], [{"Text", [], []}, {"Text", [], ["hello"]}]}] + + """ def parse_document!(document, args \\ []) do case parse_document(document, args) do - {:ok, {nodes, _cursor}} -> nodes - {:error, message, _range} -> raise message + {:ok, nodes} -> nodes + {:error, message, range} -> raise ParseError, {document, message, range} end end diff --git a/test/live_view_native/template/parser_test.exs b/test/live_view_native/template/parser_test.exs index 7b9ae3e..05df169 100644 --- a/test/live_view_native/template/parser_test.exs +++ b/test/live_view_native/template/parser_test.exs @@ -2,6 +2,10 @@ defmodule LiveViewNative.Template.ParserTest do use ExUnit.Case, async: false import LiveViewNative.Template.Parser + alias LiveViewNative.Template.ParseError + + doctest LiveViewNative.Template.Parser + test "will parse a tag" do {:ok, nodes} = """ @@ -119,98 +123,126 @@ defmodule LiveViewNative.Template.ParserTest do describe "parsing errors" do test "eof within a comment" do - {:error, _message, [start: start_pos, end: end_pos]} = "