diff --git a/.gitignore b/.gitignore index 37316150b..61e73f6d1 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ Icon Network Trash Folder Temporary Items .apdisk + +# leex/yecc output +src/vml_*.erl diff --git a/lib/game/command/quest.ex b/lib/game/command/quest.ex index cca9a522e..676af8d50 100644 --- a/lib/game/command/quest.ex +++ b/lib/game/command/quest.ex @@ -225,10 +225,10 @@ defmodule Game.Command.Quest do false -> response = - Format.wrap_lines([ + Enum.join([ gettext("You have not completed the requirements for the quest."), gettext("See {command}quest info %{id}{/command} for your current progress.)", id: progress.quest_id) - ]) + ], " ") state.socket |> @socket.echo(response) end diff --git a/lib/game/command/socials.ex b/lib/game/command/socials.ex index cc2661ab5..8c9d36acf 100644 --- a/lib/game/command/socials.ex +++ b/lib/game/command/socials.ex @@ -7,7 +7,6 @@ defmodule Game.Command.Socials do import Game.Room.Helpers, only: [find_character: 2] - alias Game.Format alias Game.Format.Socials, as: FormatSocials alias Game.Socials @@ -145,6 +144,6 @@ defmodule Game.Command.Socials do gettext("Please make sure to enter the social command. See {command}socials{/command} for the list.") ] - state.socket |> @socket.echo(Format.wrap(Enum.join(lines, " "))) + state.socket |> @socket.echo(Enum.join(lines, " ")) end end diff --git a/lib/game/format.ex b/lib/game/format.ex index 15c24244d..daf114426 100644 --- a/lib/game/format.ex +++ b/lib/game/format.ex @@ -71,54 +71,4 @@ defmodule Game.Format do |> Enum.map(fn _ -> "-" end) |> Enum.join("") end - - @doc """ - Wraps lines of text - """ - @spec wrap(String.t()) :: String.t() - def wrap(string) do - string - |> String.replace("\n", "{newline}") - |> String.replace("\r", "") - |> String.split(~r/( |{[^}]*})/, include_captures: true) - |> _wrap("", "") - end - - @doc """ - Wraps a list of text - """ - @spec wrap_lines([String.t()]) :: String.t() - def wrap_lines(lines) do - lines - |> Enum.join(" ") - |> wrap() - end - - defp _wrap([], line, string), do: join(string, line, "\n") - - defp _wrap(["{newline}" | left], line, string) do - case string do - "" -> - _wrap(left, "", line) - - _ -> - _wrap(left, "", Enum.join([string, line], "\n")) - end - end - - defp _wrap([word | left], line, string) do - test_line = "#{line} #{word}" |> Color.strip_color() |> String.trim() - - case String.length(test_line) do - len when len < 80 -> - _wrap(left, join(line, word, ""), string) - - _ -> - _wrap(left, word, join(string, String.trim(line), "\n")) - end - end - - defp join(str1, str2, joiner) do - Enum.join([str1, str2] |> Enum.reject(&(&1 == "")), joiner) - end end diff --git a/lib/game/format/channels.ex b/lib/game/format/channels.ex index f0c396d00..d4f196322 100644 --- a/lib/game/format/channels.ex +++ b/lib/game/format/channels.ex @@ -13,11 +13,14 @@ defmodule Game.Format.Channels do Example: iex> Channels.channel_say(%{name: "global", color: "red"}, {:npc, %{name: "NPC"}}, %{message: "Hello"}) - ~s([{red}global{/red}] {npc}NPC{/npc} says, {say}"Hello"{/say}) + ~s(\\\\[{red}global{/red}\\\\] {npc}NPC{/npc} says, {say}"Hello"{/say}) """ @spec channel_say(String.t(), Character.t(), map()) :: String.t() def channel_say(channel, sender, parsed_message) do - ~s([#{channel_name(channel)}] #{say(sender, parsed_message)}) + context() + |> assign(:channel_name, channel_name(channel)) + |> assign(:say, say(sender, parsed_message)) + |> Format.template("\\[[channel_name]\\] [say]") end @doc """ diff --git a/lib/game/format/listen.ex b/lib/game/format/listen.ex index fbdb1e74b..d7e7fc9cb 100644 --- a/lib/game/format/listen.ex +++ b/lib/game/format/listen.ex @@ -30,6 +30,5 @@ defmodule Game.Format.Listen do |> assign(:features, features) |> assign(:npcs, npcs) |> Format.template("{white}You can hear:{/white}[\nroom][\nfeatures][\nnpcs]") - |> Format.wrap() end end diff --git a/lib/game/format/quests.ex b/lib/game/format/quests.ex index fd3b3022b..f5b53685f 100644 --- a/lib/game/format/quests.ex +++ b/lib/game/format/quests.ex @@ -45,7 +45,7 @@ defmodule Game.Format.Quests do #{header} #{header |> Format.underline()} - #{quest.description |> Format.wrap()} + #{quest.description} #{steps |> Enum.join("\n")} """ diff --git a/lib/game/format/rooms.ex b/lib/game/format/rooms.ex index 41e892afb..1d40e09a7 100644 --- a/lib/game/format/rooms.ex +++ b/lib/game/format/rooms.ex @@ -51,8 +51,8 @@ defmodule Game.Format.Rooms do context = context() - |> assign(:room, "{green}#{room.name}{/green}") - |> assign(:zone, "{white}#{room.zone.name}{/white}") + |> assign(:room, room_name(room)) + |> assign(:zone, zone_name(room.zone)) |> assign(:features, Enum.join(features(room.features), " ")) context = diff --git a/lib/game/format/socials.ex b/lib/game/format/socials.ex index 17f66576f..af4fdebdb 100644 --- a/lib/game/format/socials.ex +++ b/lib/game/format/socials.ex @@ -44,7 +44,7 @@ defmodule Game.Format.Socials do def social_without_target(social, player) do context() |> assign(:user, Format.player_name(player)) - |> Format.template("{say}#{social.without_target}{say}") + |> Format.template("{say}#{social.without_target}{/say}") end @doc """ @@ -54,6 +54,6 @@ defmodule Game.Format.Socials do context() |> assign(:user, Format.player_name(player)) |> assign(:target, Format.name(target)) - |> Format.template("{say}#{social.with_target}{say}") + |> Format.template("{say}#{social.with_target}{/say}") end end diff --git a/lib/game/format/template.ex b/lib/game/format/template.ex index d9478f5a7..aedc81b15 100644 --- a/lib/game/format/template.ex +++ b/lib/game/format/template.ex @@ -3,8 +3,6 @@ defmodule Game.Format.Template do Template a string with variables """ - @variable_regex ~r/\[([^\[][^\]]*)\]/ - @doc """ Render a template with a context @@ -27,26 +25,52 @@ defmodule Game.Format.Template do |> Map.get(:assigns, %{}) |> Enum.into(%{}, fn {key, val} -> {to_string(key), val} end) - string - |> String.split(@variable_regex, include_captures: true) - |> Enum.map(&replace_variables(&1, context)) - |> Enum.join() + with {:ok, ast} <- VML.parse(string) do + VML.collapse(replace_variables(ast, context)) + else + {:error, _module, _error} -> + "{error}Could not parse text.{/error}" + end + end + + defp replace_variables([], _context), do: [] + + defp replace_variables([node | nodes], context) do + [replace_variable(node, context) | replace_variables(nodes, context)] + end + + defp replace_variable({:variable, space, name}, context) do + case replace_variable({:variable, name}, context) do + {:string, ""} -> + {:string, ""} + + {:string, value} -> + {:string, space <> value} + + value when is_list(value) -> + [{:string, space} | value] + end end - defp replace_variables(string, context) do - case Regex.run(@variable_regex, string) do + defp replace_variable({:variable, name}, context) do + case Map.get(context, name, "") do + "" -> + {:string, ""} + nil -> - string + {:string, ""} - [_, variable] -> - key = String.trim(variable) - leading_spaces = String.replace(variable, ~r/#{key}[\s]*/, "") + value when is_list(value) -> + value - case Map.get(context, key, "") do - "" -> "" - nil -> "" - value -> Enum.join([leading_spaces, value]) - end + value -> + {:string, value} end end + + defp replace_variable({:tag, attributes, nodes}, context) do + {:tag, attributes, replace_variables(nodes, context)} + end + + defp replace_variable(node, _context), do: node end diff --git a/lib/game/session/gmcp.ex b/lib/game/session/gmcp.ex index 779ed9278..752fa98be 100644 --- a/lib/game/session/gmcp.ex +++ b/lib/game/session/gmcp.ex @@ -316,7 +316,7 @@ defmodule Game.Session.GMCP do |> Map.take([:id, :name, :ecology, :x, :y, :map_layer]) |> Map.merge(%{ zone: zone_info(room), - description: Format.Rooms.room_description(room), + description: VML.collapse(Format.Rooms.room_description(room)), items: render_many(items), players: render_many(room, :players), npcs: render_many(room, :npcs), diff --git a/lib/vml.ex b/lib/vml.ex new file mode 100644 index 000000000..4a81b066a --- /dev/null +++ b/lib/vml.ex @@ -0,0 +1,175 @@ +defmodule VML do + @moduledoc """ + Parse VML text strings + """ + + require Logger + + @doc """ + Parse, raise on errors + """ + def parse!(string) do + case parse(string) do + {:ok, ast} -> + ast + + {:error, _type, error} -> + raise error + end + end + + @doc """ + Parse a string into an AST for processing + """ + def parse(list) when is_list(list), do: {:ok, list} + + def parse(string) do + case :vml_lexer.string(String.to_charlist(string)) do + {:ok, tokens, _} -> + parse_tokens(tokens) + + {:error, {_, _, reason}} -> + Logger.warn("Encountered a lexing error for #{inspect(string)}") + {:error, :lexer, reason} + end + end + + @doc """ + Convert a processed (no variables) AST back to a string + """ + def collapse(string) when is_binary(string), do: string + + def collapse(integer) when is_integer(integer), do: to_string(integer) + + def collapse(float) when is_float(float), do: to_string(float) + + def collapse(atom) when is_atom(atom), do: to_string(atom) + + def collapse({:tag, attributes, nodes}) do + name = Keyword.get(attributes, :name) + + case Keyword.get(attributes, :attributes) do + nil -> + "{#{name}}#{collapse(nodes)}{/#{name}}" + + attributes -> + "{#{name} #{collapse_attributes(attributes)}}#{collapse(nodes)}{/#{name}}" + end + end + + def collapse({:string, string}), do: string + + def collapse(list) when is_list(list) do + list + |> Enum.map(&collapse/1) + |> Enum.join() + end + + defp collapse_attributes(attributes) do + attributes + |> Enum.map(fn {key, value} -> + "#{key}='#{value}'" + end) + |> Enum.join(" ") + end + + @doc false + def parse_tokens(tokens) do + case :vml_parser.parse(tokens) do + {:ok, ast} -> + {:ok, pre_process(ast)} + + {:error, {_, _, reason}} -> + Logger.warn("Encountered a parsing error for #{inspect(tokens)}") + {:error, :parser, reason} + end + end + + @doc """ + Preprocess the AST + + - Turn charlists into elixir strings + - Collapse blocks of string nodes + """ + def pre_process(ast) do + ast + |> Enum.map(&process_node/1) + |> collapse_strings() + end + + @doc """ + Process a single node + + Handles strings, variables, resources, and tags. Everything else + passes through without change. + """ + def process_node({:string, string}) do + {:string, to_string(string)} + end + + def process_node({:variable, string}) do + {:variable, to_string(string)} + end + + def process_node({:variable, space, string}) do + {:variable, to_string(space), to_string(string)} + end + + def process_node({:resource, resource, id}) do + {:resource, to_string(resource), to_string(id)} + end + + def process_node({:tag, attributes, nodes}) do + attributes = Enum.map(attributes, fn {key, value} -> + {key, process_attribute(key, value)} + end) + + {:tag, attributes, pre_process(nodes)} + end + + def process_node(node), do: node + + defp process_attribute(:name, value) do + value + |> Enum.map(fn {:string, value} -> + to_string(value) + end) + |> Enum.join() + end + + defp process_attribute(:attributes, attributes) do + Enum.map(attributes, fn attribute -> + process_attribute(:attribute, attribute) + end) + end + + defp process_attribute(:attribute, {name, value}) do + value = + value + |> Enum.map(&to_string/1) + |> Enum.join() + + {to_string(name), value} + end + + @doc """ + Collapse string nodes next to each other into a single node + + Recurses through the list adding the newly collapsed node into the processing stream. + + iex> VML.collapse_strings([string: "hello", string: " ", string: "world"]) + [string: "hello world"] + + iex> VML.collapse_strings([variable: "name", string: ",", string: " ", string: "hello", string: " ", string: "world"]) + [variable: "name", string: ", hello world"] + """ + def collapse_strings([]), do: [] + + def collapse_strings([{:string, string1}, {:string, string2} | nodes]) do + collapse_strings([{:string, to_string(string1) <> to_string(string2)} | nodes]) + end + + def collapse_strings([node | nodes]) do + [node | collapse_strings(nodes)] + end +end diff --git a/lib/web/views/admin/room_view.ex b/lib/web/views/admin/room_view.ex index 40c3fc64b..5bb1ee157 100644 --- a/lib/web/views/admin/room_view.ex +++ b/lib/web/views/admin/room_view.ex @@ -106,7 +106,6 @@ defmodule Web.Admin.RoomView do room |> FormatRooms.room_description() - |> Format.wrap() |> Color.format() |> raw() end @@ -116,7 +115,6 @@ defmodule Web.Admin.RoomView do def listen(room) do text = room.listen - |> Format.wrap() |> Color.format() |> raw() diff --git a/src/vml_lexer.xrl b/src/vml_lexer.xrl new file mode 100644 index 000000000..53e21626c --- /dev/null +++ b/src/vml_lexer.xrl @@ -0,0 +1,40 @@ +Definitions. + +TagOpen = { +EscapedTagOpen = \\{ +ClosingTagOpen = {/ +TagClose = } +EscapedTagClose = \\} +OpeningSlash = \\ +ClosingSlash = / +VariableOpen = \[ +VariableClose = \] +EscapedVariableOpen = \\\[ +EscapedVariableClose = \\\] +Word = [^{}\n\[\]=\s'":\\]+ +Colon = : +Space = \s+ +Quote = ['"] +NewLine = (\n|\n\r|\r) +Equal = = + +Rules. + +{Word} : {token, {word, TokenLine, TokenChars}}. +{Quote} : {token, {quote, TokenLine, TokenChars}}. +{Space} : {token, {space, TokenLine, TokenChars}}. +{Colon} : {token, {colon, TokenLine, TokenChars}}. +{Equal} : {token, {'=', TokenLine, TokenChars}}. +{NewLine} : {token, {new_line, TokenLine, TokenChars}}. +{TagOpen} : {token, {'{', TokenLine, TokenChars}}. +{EscapedTagOpen} : {token, {'\\{', TokenLine, TokenChars}}. +{ClosingTagOpen} : {token, {'{/', TokenLine, TokenChars}}. +{OpeningSlash} : {token, {'\\', TokenLine, TokenChars}}. +{TagClose} : {token, {'}', TokenLine, TokenChars}}. +{EscapedTagClose} : {token, {'\\}', TokenLine, TokenChars}}. +{VariableOpen} : {token, {'[', TokenLine, TokenChars}}. +{VariableClose} : {token, {']', TokenLine, TokenChars}}. +{EscapedVariableOpen} : {token, {'\\[', TokenLine, TokenChars}}. +{EscapedVariableClose} : {token, {'\\]', TokenLine, TokenChars}}. + +Erlang code. diff --git a/src/vml_parser.yrl b/src/vml_parser.yrl new file mode 100644 index 000000000..ce34a0d2d --- /dev/null +++ b/src/vml_parser.yrl @@ -0,0 +1,101 @@ +Nonterminals +text +markup +tag +tag_name +attributes +attribute +attribute_value +variable +resource +. + +Terminals +word +space +quote +colon +new_line +'{' +'{/' +'}' +'[' +']' +'=' +'\\' +'\\[' +'\\]' +'\\{' +'\\}' +. + +Rootsymbol text. + +text -> markup text : ['$1' | '$2']. +text -> markup : ['$1']. + +markup -> resource : '$1'. +markup -> tag : '$1'. +markup -> variable : '$1'. + +markup -> word : string('$1'). +markup -> space : string('$1'). +markup -> quote : string('$1'). +markup -> colon : string('$1'). +markup -> new_line : string('$1'). + +markup -> '=' : string('$1'). +markup -> '\\' : string('$1'). +markup -> '\\[' : string('$1'). +markup -> '\\]' : string('$1'). +markup -> '\\{' : string('$1'). +markup -> '\\}' : string('$1'). + +tag -> '{' tag_name '}' text '{/' tag_name '}' : tag('$2', '$4', '$6'). +tag -> '{' tag_name '}' '{/' tag_name '}' : tag('$2', [], '$5'). +tag -> '{' tag_name space attributes '}' text '{/' tag_name '}' : tag('$2', '$4', '$6', '$8'). + +tag_name -> word tag_name : [string('$1') | '$2']. +tag_name -> colon tag_name : [string('$1') | '$2']. +tag_name -> word : [string('$1')]. + +attributes -> attribute space attributes : ['$1' | '$3']. +attributes -> attribute : ['$1']. + +attribute -> word '=' quote attribute_value quote : attribute('$1', '$4'). + +attribute_value -> word space attribute_value : [val('$1') | [val('$2') | '$3']]. +attribute_value -> word : [val('$1')]. + +resource -> '{' '{' word colon word '}' '}' : {resource, val('$3'), val('$5')}. + +variable -> '[' word ']' : {variable, val('$2')}. +variable -> '[' space word ']' : {variable, val('$2'), val('$3')}. +variable -> '[' new_line word ']' : {variable, val('$2'), val('$3')}. + +Erlang code. + +string(V) -> {string, val(V)}. +val({_, _, V}) -> V. + +attribute(Name, Val) -> {val(Name), Val}. +attributes(A) -> {attributes, A}. + +tag(StartName, Markup, EndName) -> + if + StartName =:= EndName -> + {tag, [{name, StartName}], Markup}; + true -> + return_error(1, tag_mismatch_msg(StartName, EndName)) + end. + +tag(StartName, Attributes, Markup, EndName) -> + if + StartName =:= EndName -> + {tag, [{name, StartName}, attributes(Attributes)], Markup}; + true -> + return_error(1, tag_mismatch_msg(StartName, EndName)) + end. + +tag_mismatch_msg(StartName, EndName) -> + lists:concat(['\'', val(StartName), '\' does not match closing tag name \'', val(EndName), '\'']). diff --git a/test/game/command/skills_test.exs b/test/game/command/skills_test.exs index 4de3ae0e9..f47c4aa43 100644 --- a/test/game/command/skills_test.exs +++ b/test/game/command/skills_test.exs @@ -19,7 +19,7 @@ defmodule Game.Command.SkillsTest do points: 2, command: "slash", description: "Slash", - user_text: "Slash at your {target}", + user_text: "Slash at your [target]", usee_text: "You were slashed at", effects: [%{kind: "damage", type: "slashing", amount: 5}], }) diff --git a/test/game/command/socials_test.exs b/test/game/command/socials_test.exs index 687923c3b..969bb01eb 100644 --- a/test/game/command/socials_test.exs +++ b/test/game/command/socials_test.exs @@ -18,8 +18,8 @@ defmodule Game.Command.SocialsTest do id: 1, name: "Smile", command: "smile", - with_target: "{user} smiles at {target}", - without_target: "{user} smiles" + with_target: "[user] smiles at [target]", + without_target: "[user] smiles" } |> insert_social() %Social{id: 2, name: "Laugh", command: "laugh"} |> insert_social() diff --git a/test/game/format_test.exs b/test/game/format_test.exs index 9136121de..fa2b7b1c3 100644 --- a/test/game/format_test.exs +++ b/test/game/format_test.exs @@ -1,35 +1,7 @@ defmodule Game.FormatTest do use ExUnit.Case - doctest Game.Format alias Game.Format - describe "line wrapping" do - test "single line" do - assert Format.wrap("one line") == "one line" - end - - test "wraps at 80 chars" do - assert Format.wrap("this line will be split up into two lines because it is longer than 80 characters") == - "this line will be split up into two lines because it is longer than 80\ncharacters" - end - - test "wraps at 80 chars - ignores {color} codes when counting" do - line = "{blue}this{/blue} line {yellow}will be{/yellow} split up into two lines because it is longer than 80 characters" - assert Format.wrap(line) == - "{blue}this{/blue} line {yellow}will be{/yellow} split up into two lines because it is longer than 80\ncharacters" - end - - test "wraps at 80 chars - ignores {command} codes when counting" do - line = - "{command send='help text'}this{/command} line {yellow}will be{/yellow} split up into two lines because it is longer than 80 characters" - assert Format.wrap(line) == - "{command send='help text'}this{/command} line {yellow}will be{/yellow} split up into two lines because it is longer than 80\ncharacters" - end - - test "wraps and does not chuck newlines" do - assert Format.wrap("hi\nthere") == "hi\nthere" - assert Format.wrap("hi\n\n\nthere") == "hi\n\n\nthere" - end - end + doctest Format end diff --git a/test/vml_test.exs b/test/vml_test.exs new file mode 100644 index 000000000..fd2312709 --- /dev/null +++ b/test/vml_test.exs @@ -0,0 +1,106 @@ +defmodule VMLTest do + use ExUnit.Case + + doctest VML + + describe "parsing simple text" do + test "skipping an already parsed ast" do + {:ok, tokens} = VML.parse("hi there") + {:ok, ^tokens} = VML.parse("hi there") + end + + test "just strings" do + {:ok, tokens} = VML.parse("hi there") + + assert tokens == [string: "hi there"] + end + + test "simple tag" do + {:ok, tokens} = VML.parse("{red}hi there{/red}") + + assert tokens == [{:tag, [name: "red"], [string: "hi there"]}] + end + + test "tag in a tag" do + {:ok, tokens} = VML.parse("{say}hi there {npc}Guard{/npc}{/say}") + + assert tokens == [{:tag, [name: "say"], [{:string, "hi there "}, {:tag, [name: "npc"], [string: "Guard"]}]}] + end + + test "a template variable" do + {:ok, tokens} = VML.parse("hello [name]") + + assert tokens == [{:string, "hello "}, {:variable, "name"}] + end + + test "a template variable with spaces" do + {:ok, tokens} = VML.parse("hello [ name]") + assert tokens == [{:string, "hello "}, {:variable, " ", "name"}] + + {:ok, tokens} = VML.parse("hello [\nname]") + assert tokens == [{:string, "hello "}, {:variable, "\n", "name"}] + end + + test "a resource variable" do + {:ok, tokens} = VML.parse("welcome to {{zone:1}}") + + assert tokens == [{:string, "welcome to "}, {:resource, "zone", "1"}] + end + + test "special characters" do + {:ok, tokens} = VML.parse("=") + + assert tokens == [{:string, "="}] + end + + test "map colors" do + {:ok, tokens} = VML.parse("{map:blue}\\[ \\]{/map:blue}") + + assert tokens == [{:tag, [name: "map:blue"], [{:string, "\\[ \\]"}]}] + end + + test "tag a attribute" do + {:ok, tokens} = VML.parse("{command send='help say'}Say{/command}") + + assert tokens == [{:tag, [name: "command", attributes: [{"send", "help say"}]], [{:string, "Say"}]}] + end + + test "tag attributes" do + {:ok, tokens} = VML.parse("{command send='help say' click='false'}Say{/command}") + + assert tokens == [{:tag, [name: "command", attributes: [{"send", "help say"}, {"click", "false"}]], [{:string, "Say"}]}] + end + end + + describe "collapse AST back to a string" do + test "simple" do + string = VML.collapse([{:string, "Hello"}]) + assert string == "Hello" + end + + test "multiple strings" do + string = VML.collapse([{:string, "Hello"}, {:string, ", world"}]) + assert string == "Hello, world" + end + + test "with tags" do + string = VML.collapse([{:tag, [name: "red"], [{:string, "Hello"}]}]) + assert string == "{red}Hello{/red}" + end + + test "nested lists" do + string = VML.collapse([[{:tag, [name: "red"], [{:string, "Hello"}]}], {:string, "World"}]) + assert string == "{red}Hello{/red}World" + end + + test "tag a attribute" do + string = VML.collapse([{:tag, [name: "command", attributes: [{"send", "help say"}]], [{:string, "Say"}]}]) + assert string == "{command send='help say'}Say{/command}" + end + + test "tag a attributes" do + string = VML.collapse([{:tag, [name: "command", attributes: [{"send", "help say"}, {"click", "false"}]], [{:string, "Say"}]}]) + assert string == "{command send='help say' click='false'}Say{/command}" + end + end +end