diff --git a/.travis.yml b/.travis.yml index 51131c5..9c3c850 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,4 @@ -language: - elixir +language: elixir matrix: include: @@ -7,6 +6,8 @@ matrix: elixir: 1.4.5 - otp_release: 20.0 elixir: 1.5.0 + - otp_release: 26.2.1 + elixir: 1.16.0 script: - - mix espec --trace + - mix test diff --git a/lib/shorter_maps.ex b/lib/shorter_maps.ex index 6b03696..5600c09 100644 --- a/lib/shorter_maps.ex +++ b/lib/shorter_maps.ex @@ -28,8 +28,11 @@ defmodule ShorterMaps do """ defmacro sigil_m(term, modifiers) - defmacro sigil_m({:<<>>, [line: line], [string]}, modifiers) do - do_sigil_m(string, line, modifier(modifiers, @default_modifier_m)) + defmacro sigil_m({:<<>>, fields, [string]}, modifiers) do + case Keyword.get(fields, :line) do + nil -> raise ArgumentError, "interpolation is not supported with the ~m sigil" + line -> do_sigil_m(string, line, modifier(modifiers, @default_modifier_m)) + end end defmacro sigil_m({:<<>>, _, _}, _modifiers) do @@ -61,10 +64,16 @@ defmodule ShorterMaps do %Person{id: 100, other_key: :default_val} """ + defmacro sigil_M(term, modifiers) - defmacro sigil_M({:<<>>, [line: line], [string]}, modifiers) do - do_sigil_m(string, line, modifier(modifiers, @default_modifier_M)) + + defmacro sigil_M({:<<>>, fields, [string]}, modifiers) do + case Keyword.get(fields, :line) do + nil -> raise ArgumentError, "interpolation is not supported with the ~M sigil" + line -> do_sigil_m(string, line, modifier(modifiers, @default_modifier_M)) + end end + defmacro sigil_M({:<<>>, _, _}, _modifiers) do raise ArgumentError, "interpolation is not supported with the ~M sigil" end @@ -73,12 +82,13 @@ defmodule ShorterMaps do defp do_sigil_m("%" <> _rest, _line, ?s) do raise(ArgumentError, "structs can only consist of atom keys") end + defp do_sigil_m(raw_string, line, modifier) do with {:ok, struct_name, rest} <- get_struct(raw_string), {:ok, old_map, rest} <- get_old_map(rest), {:ok, keys_and_values} <- expand_variables(rest, modifier) do final_string = "%#{struct_name}{#{old_map}#{keys_and_values}}" - #IO.puts("#{raw_string} => #{final_string}") # For debugging expansions gone wrong. + # IO.puts("#{raw_string} => #{final_string}") # For debugging expansions gone wrong. Code.string_to_quoted!(final_string, file: __ENV__.file, line: line) else {:error, step, reason} -> @@ -90,23 +100,28 @@ defmodule ShorterMaps do # expecting something like: '%StructName key1, key2' -or- '%StructName oldmap|key1, key2' # returns {:ok, old_map, keys_and_vars} | {:ok, "", keys_and_vars} defp get_struct("%" <> rest) do - [struct_name|others] = String.split(rest, " ") + [struct_name | others] = String.split(rest, " ") body = Enum.join(others, " ") {:ok, struct_name, body} end + defp get_struct(no_struct), do: {:ok, "", no_struct} @re_prefix "[_^]" - @re_varname ~S"[a-zA-Z0-9_]\w*[?!]?" # use ~S to get a real \ + # use ~S to get a real \ + @re_varname ~S"[a-zA-Z0-9_]\w*[?!]?" @doc false # expecting something like "old_map|key1, key2" -or- "key1, key2" # returns {:ok, "#{old_map}|", keys_and_vars} | {:ok, "", keys_and_vars} defp get_old_map(string) do cond do - string =~ ~r/\A\s*#{@re_varname}\s*\|/ -> # make sure this is a map update pipe - [old_map|rest] = String.split(string, "|") - new_body = Enum.join(rest, "|") # put back together unintentionally split things + # make sure this is a map update pipe + string =~ ~r/\A\s*#{@re_varname}\s*\|/ -> + [old_map | rest] = String.split(string, "|") + # put back together unintentionally split things + new_body = Enum.join(rest, "|") {:ok, "#{old_map}|", new_body} + true -> {:ok, "", string} end @@ -121,36 +136,44 @@ defmodule ShorterMaps do # commas. defp expand_variables(string, modifier) do - result = string - |> String.split(",") - |> identify_entries() - |> Enum.map(fn s -> - cond do - s =~ ~r/\A\s*#{@re_prefix}?#{@re_varname}(\(\s*\))?\s*\Z/ -> - s - |> String.trim - |> expand_variable(modifier) - true -> s - end - end) - |> Enum.join(",") - {:ok, result} + result = + string + |> String.split(",") + |> identify_entries() + |> Enum.map(fn s -> + cond do + s =~ ~r/\A\s*#{@re_prefix}?#{@re_varname}(\(\s*\))?\s*\Z/ -> + s + |> String.trim() + |> expand_variable(modifier) + + true -> + s + end + end) + |> Enum.join(",") + + {:ok, result} end @doc false defp identify_entries(candidates, partial \\ "", acc \\ []) - defp identify_entries([], "", acc), do: acc |> Enum.reverse + defp identify_entries([], "", acc), do: acc |> Enum.reverse() + defp identify_entries([], remainder, _acc) do # we failed, use code module to raise a syntax error: Code.string_to_quoted!(remainder) end - defp identify_entries([h|t], partial, acc) do - entry = case partial do - "" -> h - _ -> partial <> "," <> h - end + + defp identify_entries([h | t], partial, acc) do + entry = + case partial do + "" -> h + _ -> partial <> "," <> h + end + if check_entry(entry, [:map, :list]) do - identify_entries(t, "", [entry|acc]) + identify_entries(t, "", [entry | acc]) else identify_entries(t, entry, acc) end @@ -158,24 +181,26 @@ defmodule ShorterMaps do @doc false defp check_entry(_entry, []), do: false - defp check_entry(entry, [:map|rest]) do + + defp check_entry(entry, [:map | rest]) do case Code.string_to_quoted("%{#{entry}}") do {:ok, _} -> true {:error, _} -> check_entry(entry, rest) end end - defp check_entry(entry, [:list|rest]) do + + defp check_entry(entry, [:list | rest]) do case Code.string_to_quoted("[#{entry}]") do {:ok, _} -> true {:error, _} -> check_entry(entry, rest) end end - @doc false defp expand_variable(var, ?s) do "\"#{fix_key(var)}\" => #{var}" end + defp expand_variable(var, ?a) do "#{fix_key(var)}: #{var}" end @@ -183,15 +208,16 @@ defmodule ShorterMaps do @doc false defp fix_key("_" <> name), do: name defp fix_key("^" <> name), do: name + defp fix_key(name) do String.replace_suffix(name, "()", "") end @doc false defp modifier([], default), do: default - defp modifier([mod], _default) when mod in 'as', do: mod + defp modifier([mod], _default) when mod in ~c"as", do: mod + defp modifier(_, _default) do raise(ArgumentError, "only these modifiers are supported: s, a") end - end diff --git a/mix.exs b/mix.exs index cc301b9..2632f81 100644 --- a/mix.exs +++ b/mix.exs @@ -9,16 +9,14 @@ defmodule ShorterMaps.Mixfile do app: :shorter_maps, version: @version, elixir: "~> 1.0", - build_embedded: Mix.env == :prod, - start_permanent: Mix.env == :prod, + build_embedded: Mix.env() == :prod, + start_permanent: Mix.env() == :prod, deps: deps(), # Hex package: hex_package(), description: "~M sigil for map shorthand. `~M{id, name} ~> %{id: id, name: name}`", # Docs - name: "ShorterMaps", - # Testing - preferred_cli_env: [espec: :test], + name: "ShorterMaps" ] end @@ -27,16 +25,13 @@ defmodule ShorterMaps.Mixfile do end defp hex_package do - [maintainers: ["Chris Meyer"], - licenses: ["MIT"], - links: %{"GitHub" => @repo_url}] + [maintainers: ["Chris Meyer"], licenses: ["MIT"], links: %{"GitHub" => @repo_url}] end defp deps do [ {:ex_doc, ">= 0.0.0", only: :dev}, - {:earmark, ">= 0.0.0", only: :dev}, - {:espec, "~> 1.2", only: [:dev, :test]}, + {:earmark, ">= 0.0.0", only: :dev} ] end end diff --git a/mix.lock b/mix.lock index 44dd252..c38fdd3 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ -%{"earmark": {:hex, :earmark, "1.0.3", "89bdbaf2aca8bbb5c97d8b3b55c5dd0cff517ecc78d417e87f1d0982e514557b", [:mix], []}, - "espec": {:hex, :espec, "1.4.2", "7dda1a2369ca597d5d28614b2dc69f1b19c67e76e01b2a74b04a462d832a6e1d", [:mix], [{:meck, "0.8.4", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.14.5", "c0433c8117e948404d93ca69411dd575ec6be39b47802e81ca8d91017a0cf83c", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}, - "meck": {:hex, :meck, "0.8.4", "59ca1cd971372aa223138efcf9b29475bde299e1953046a0c727184790ab1520", [:make, :rebar], []}, - "pavlov": {:hex, :pavlov, "0.2.3", "9072029af61301463a6e273e2829e9eab14bd1e7a5c381886d5166e6739e6fcf", [:mix], [{:meck, "~> 0.8.2", [hex: :meck, optional: false]}]}} +%{ + "earmark": {:hex, :earmark, "1.0.3", "89bdbaf2aca8bbb5c97d8b3b55c5dd0cff517ecc78d417e87f1d0982e514557b", [:mix], [], "hexpm", "0fdcd651f9689e81cda24c8e5d06947c5aca69dbd8ce3d836b02bcd0c6004592"}, + "ex_doc": {:hex, :ex_doc, "0.14.5", "c0433c8117e948404d93ca69411dd575ec6be39b47802e81ca8d91017a0cf83c", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm", "5c30e436a5acfdc2fd8fe6866585fcaf30f434c611d8119d4f3390ced2a550f3"}, + "meck": {:hex, :meck, "0.8.4", "59ca1cd971372aa223138efcf9b29475bde299e1953046a0c727184790ab1520", [:make, :rebar], [], "hexpm", "2cdfbd0edd8f62b3d2061efc03c0e490282dd2ea6de44e15d2006e83f4f8eead"}, + "pavlov": {:hex, :pavlov, "0.2.3", "9072029af61301463a6e273e2829e9eab14bd1e7a5c381886d5166e6739e6fcf", [:mix], [{:meck, "~> 0.8.2", [hex: :meck, optional: false]}]}, +} diff --git a/spec/spec_helper.exs b/spec/spec_helper.exs deleted file mode 100644 index db5aba4..0000000 --- a/spec/spec_helper.exs +++ /dev/null @@ -1,9 +0,0 @@ -ESpec.configure fn(config) -> - config.before fn(tags) -> - {:shared, hello: :world, tags: tags} - end - - config.finally fn(_shared) -> - :ok - end -end diff --git a/test/shorter_maps_test.exs b/test/shorter_maps_test.exs new file mode 100644 index 0000000..9ff408a --- /dev/null +++ b/test/shorter_maps_test.exs @@ -0,0 +1,401 @@ +defmodule ShorterMapsTest do + alias ExUnit.TestModule + use ExUnit.Case + import ShorterMaps + + def eval(quoted_code), do: fn -> Code.eval_quoted(quoted_code) end + + describe "map construction ~M" do + test "with one key" do + key = "value" + assert %{key: "value"} = ~M{key} + end + + test "with many keys" do + key_1 = "value_1" + key_2 = :value_2 + key_3 = 3 + assert %{key_1: "value_1", key_2: :value_2, key_3: 3} = ~M{key_1, key_2, key_3} + end + + test "with mixed keys" do + key_1 = "val_1" + key_2_alt = :val2 + assert %{key_1: "val_1", key_2: :val2} = ~M{key_1, key_2: key_2_alt} + end + + test "raises on invalid varnames" do + quoted = quote do: ~M{4asdf} + assert_raise(SyntaxError, eval(quoted)) + end + end + + describe "map construction ~m" do + test "with one key" do + a_key = :test_value + assert %{"a_key" => :test_value} = ~m{a_key} + end + + test "with many keys" do + first_name = "chris" + last_name = "meyer" + + assert %{"first_name" => "chris", "last_name" => "meyer"} = ~m{first_name, last_name} + end + + test "with mixed keys" do + key_1 = "value_1" + key_2_alt = :val_2 + + assert %{"key_1" => "value_1", "key_2" => :val_2} = ~m{key_1, "key_2" => key_2_alt} + end + + test "raises on invalid varnames" do + code = quote do: ~m{4asdf} + assert_raise(SyntaxError, eval(code)) + end + end + + describe "inline pattern matches" do + test "for ~M" do + ~M{key_1, key_2} = %{key_1: 1, key_2: 2} + assert 1 = key_1 + assert 2 = key_2 + end + + test "for ~m" do + ~m{key_1, key_2} = %{"key_1" => 1, "key_2" => 2} + assert 1 = key_1 + assert 2 = key_2 + end + + test "with mixed_keys" do + ~M{key_1, key_2: key_2_alt} = %{key_1: :val_1, key_2: "val 2"} + assert :val_1 = key_1 + assert "val 2" = key_2_alt + end + + test "fails to match when there is no match" do + code = quote do: ~M{key_1} = %{key_2: 1} + assert_raise(MatchError, eval(code)) + end + end + + describe "function head matches in module" do + defmodule TestModule do + def test(~M{key_1, key_2}), do: {:first, key_1, key_2} + def test(~m{key_1}), do: {:second, key_1} + def test(_), do: :third + end + + test "matches in module function heads" do + assert {:first, 1, 2} = TestModule.test(%{key_1: 1, key_2: 2}) + assert {:second, 1} = TestModule.test(%{"key_1" => 1}) + end + end + + describe "function head matches in anonymous functions" do + test "matches anonymous function heads" do + fun = fn + ~m{foo} -> {:first, foo} + ~M{foo} -> {:second, foo} + _ -> :no_match + end + + assert fun.(%{"foo" => "bar"}) == {:first, "bar"} + assert fun.(%{foo: "barr"}) == {:second, "barr"} + assert fun.(%{baz: "bong"}) == :no_match + end + end + + describe "struct syntax" do + defmodule TestStruct do + defstruct a: nil + end + + defmodule TestStruct.Child.GrandChild.Struct do + defstruct a: nil + end + + test "of construction" do + a = 5 + assert %TestStruct{a: 5} = ~M{%TestStruct a} + end + + test "of alias resolution" do + alias TestStruct, as: TS + a = 3 + assert %TS{a: 3} = ~M{%TS a} + end + + test "of child alias resolution" do + alias TestStruct.Child.GrandChild.{Struct} + a = 0 + assert %TestStruct.Child.GrandChild.Struct{a: 0} = ~M{%Struct a} + end + + test "of case pattern-match" do + a = 5 + + case %TestStruct{a: 0} do + ~M{%TestStruct ^a} -> raise("shouldn't have matched") + ~M{%TestStruct _a} -> :ok + end + end + + # TODO: figure out why this test doesn't work. A manual test in a compiled + # .ex does raise a KeyError, but not this one: + # test"raises on invalid keys" do + # code = quote do: b = 5; ~m{%TestStruct b} + # expect eval(code) |> to(raise_exception(KeyError)) + # end + + test "works for a local module" do + defmodule InnerTestStruct do + defstruct a: nil + + def test() do + a = 5 + ~M{%__MODULE__ a} + end + end + + # need to use the :__struct__ version due to compile order? + assert %{__struct__: InnerTestStruct, a: 5} = InnerTestStruct.test() + end + end + + describe "update syntax ~M" do + test "with one key" do + initial = %{a: 1, b: 2, c: 3} + a = 10 + assert %{a: 10, b: 2, c: 3} = ~M{initial|a} + end + + test "allows homogenous keys" do + initial = %{a: 1, b: 2, c: 3} + {a, b} = {6, 7} + assert %{a: 6, b: 7, c: 3} = ~M{initial|a, b} + end + + test "allows mixed keys" do + initial = %{a: 1, b: 2, c: 3} + {a, d} = {6, 7} + assert %{a: 6, b: 7, c: 3} = ~M{initial|a, b: d} + end + + test "can update a struct" do + old_struct = %Range{first: 1, last: 2, step: 1} + last = 3 + %Range{first: 1, last: 3} = ~M{old_struct|last} + end + + defmodule TestStructForUpdate do + defstruct a: 1, b: 2, c: 3 + end + + test "of multiple key update" do + old_struct = %TestStructForUpdate{a: 10, b: 20, c: 30} + a = 3 + b = 4 + assert %TestStructForUpdate{a: 3, b: 4, c: 30} = ~M{old_struct|a, b} + end + end + + describe "update syntax ~m" do + test "with one key" do + initial = %{"a" => 1, "b" => 2, "c" => 3} + a = 10 + assert %{"a" => 10, "b" => 2, "c" => 3} = ~m{initial|a} + end + + test "allows homogenous keys" do + initial = %{"a" => 1, "b" => 2, "c" => 3} + {a, b} = {6, 7} + assert %{"a" => 6, "b" => 7, "c" => 3} = ~m{initial|a, b} + end + + test "allows mixed keys" do + initial = %{"a" => 1, "b" => 2, "c" => 3} + {a, d} = {6, 7} + assert %{"a" => 6, "b" => 7, "c" => 3} = ~m{initial|a, "b" => d} + end + end + + describe "pin syntax ~M" do + test "happy case" do + matching = 5 + ~M{^matching} = %{matching: 5} + end + + test "sad case" do + not_matching = 5 + + case %{not_matching: 6} do + ~M{^not_matching} -> raise("matched when testshouldn't have") + _ -> :ok + end + end + end + + describe "pin syntax ~m" do + test "happy case" do + matching = 5 + ~m{^matching} = %{"matching" => 5} + end + + test "sad case" do + not_matching = 5 + + case %{"not_matching" => 6} do + ~m{^not_matching} -> raise("matched when testshouldn't have") + _ -> :ok + end + end + end + + describe "ignore syntax ~M" do + test "happy case" do + ~M{_ignored, real_val} = %{ignored: 5, real_val: 19} + assert 19 = real_val + end + + test "sad case" do + case %{real_val: 19} do + ~M{_not_present, _real_val} -> raise("matched when testshouldn't have") + _ -> :ok + end + end + end + + describe "ignore syntax ~m" do + test "happy case" do + ~m{_ignored, real_val} = %{"ignored" => 5, "real_val" => 19} + assert 19 = real_val + end + + test "sad case" do + case %{"real_val" => 19} do + ~m{_not_present, _real_val} -> raise("matched when testshouldn't have") + _ -> :ok + end + end + end + + def blah do + :bleh + end + + describe "zero-arity" do + test "Kernel function" do + assert %{node: node()} == ~M{node()} + end + + test "local function" do + assert %{blah: :bleh} == ~M{blah()} + end + + test "calls the function at run-time" do + mypid = self() + assert %{self: ^mypid} = ~M{self()} + end + end + + describe "nested sigils" do + test "two levels" do + [a, b, c] = [1, 2, 3] + assert %{a: ^a, b: %{b: ^b, c: ^c}} = ~M{a, b: ~M(b, c)} + end + end + + describe "literals" do + test "adding" do + a = 1 + assert %{a: ^a, b: 3} = ~M{a, b: a+2} + end + + test "function call" do + a = [] + %{a: [], len: 0} = ~M{a, len: length(a)} + end + + test "embedded shortermap" do + a = 1 + b = 2 + assert %{a: ^a, b: %{b: ^b}} = ~M{a, b: ~M(b)} + end + + test "embedded commas" do + a = 1 + assert %{a: ^a, b: <<1, 2, 3>>} = ~M{a, b: <<1, 2, 3>>} + end + + test "function call with arguments" do + a = :hey + assert %{a: ^a, b: 3} = ~M{a, b: div(10, 3)} + end + + test "pipeline" do + a = :hey + assert %{a: ^a, b: "hey"} = ~M{a, b: a |> Atom.to_string} + end + + test "string keys" do + a = "blah" + b = "bleh" + + assert %{"a" => ^a, "b" => %{"a" => ^a, "b" => ^b}} = ~m{a, "b" => ~m(a, b)} + end + + test "string interpolation" do + a = "blah" + b = "bleh" + assert %{a: ^a, b: "blehbleh, c"} = ~M(a, b: "#{b <> b}, c") + end + end + + describe "regressions and bugfixes" do + test "of mixed-mode parse error" do + a = 5 + assert %{key: [1, ^a, 2]} = ~M{key: [1, a, 2]} + end + + test "of import shadowing" do + defmodule Test do + import ShorterMaps + + def test do + get_struct(:a) + get_old_map(:a) + expand_variables(:a, :b) + expand_variable(:a, :b) + identify_entries(:a, :b, :c) + check_entry(:a, :b) + expand_variable(:a, :b) + fix_key(:a) + modifier(:a, :b) + do_sigil_m(:a, :b) + end + + def get_struct(a), do: ~M{a} + def get_old_map(a), do: a + def expand_variables(a, b), do: {a, b} + def expand_variable(a, b), do: {a, b} + def identify_entries(a, b, c), do: {a, b, c} + def check_entry(a, b), do: {a, b} + def fix_key(a), do: a + def modifier(a, b), do: {a, b} + def do_sigil_m(a, b), do: {a, b} + end + end + + test "of varname variations" do + a? = 1 + assert %{a?: ^a?} = ~M{a?} + a5 = 2 + assert %{a5: ^a5} = ~M{a5} + a! = 3 + assert %{a!: ^a!} = ~M{a!} + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()