diff --git a/Elixir/.formatter.exs b/Elixir/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/Elixir/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/Elixir/.gitignore b/Elixir/.gitignore new file mode 100644 index 0000000..c5bbb32 --- /dev/null +++ b/Elixir/.gitignore @@ -0,0 +1,31 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +rsv-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore my local setup +/shell.nix +/.envrc +/.direnv/ \ No newline at end of file diff --git a/Elixir/README.md b/Elixir/README.md index a977704..6ba9a1f 100644 --- a/Elixir/README.md +++ b/Elixir/README.md @@ -1,3 +1,11 @@ # RSV Implementation for Elixir -See https://github.com/Stenway/RSV-Challenge/issues/3 \ No newline at end of file +See https://github.com/Stenway/RSV-Challenge/issues/3 + +Be sure to have Elixir and Mix installed. The tests depend on the `Jason` dependency for parsing the test fixtures, so be sure to run `mix deps.get`. + +The tests can then be run with `mix test`. + +The tests relies on test fixtures found in `../TestFiles`. Currently all the valid and invalid tests pass, but this might not be the case if an edge case has been added that this Elixir implementation doesn't account for. Should that be the case, please go ahead and fix it :) + +The `RSV` module exposes two functions, `encode!/1` and `decode!/1`. Both will raise if they get invalid data. Many improvements could be made; non-bang versions of the two functions could be added (versions of the functions that returns ok/error-tuples instead of raising). Also, it would be interesting to support streaming. Feel free to add all this. diff --git a/Elixir/lib/rsv.ex b/Elixir/lib/rsv.ex new file mode 100644 index 0000000..cc07637 --- /dev/null +++ b/Elixir/lib/rsv.ex @@ -0,0 +1,57 @@ +defmodule RSV do + @moduledoc """ + Documentation for `RSV`. + """ + + defmodule ParseError do + defexception [:message] + end + + @terminate_value 0xFF + @terminate_row 0xFD + @null 0xFE + + @doc """ + Encode data containing lists of lists of String and Nil + """ + def encode!(data_rows) when is_list(data_rows) do + for row <- data_rows, into: <<>> do + <>, do: encode_value(value))::binary, @terminate_row>> + end + end + + defp encode_value(nil), do: <<@null, @terminate_value>> + defp encode_value(value), do: <> + + @doc """ + Decode RSV encoding data + """ + def decode!(<>), do: do_decode(data, <<>>, [], []) + + defp do_decode(<<>>, <<>>, [], acc), do: Enum.reverse(acc) + + defp do_decode(<>, value_acc, row_acc, acc) when char < 0xF8, + do: do_decode(rest, value_acc <> <>, row_acc, acc) + + defp do_decode(<<@terminate_row, rest::binary>>, <<>>, current_row, acc), + do: do_decode(rest, <<>>, [], [current_row | acc]) + + defp do_decode(<<@null, @terminate_value, rest::binary>>, <<>>, current_row, acc), + do: do_decode(rest, <<>>, current_row ++ [nil], acc) + + defp do_decode(<<@terminate_value, rest::binary>>, current_value, current_row, acc) do + if String.valid?(current_value) do + do_decode(rest, <<>>, current_row ++ [current_value], acc) + else + # We are strict about UTF-8 encoding and decoding + raise ParseError, "Invalid string data: #{inspect(current_value)}" + end + end + + # Parse Errors + defp do_decode(<<>>, "", _row, _acc), + do: raise(ParseError, "Reached end of data, expected an end of row terminator") + + defp do_decode(<>, _, _, _) when char >= 0xF8, + do: raise(ParseError, "Encountered an invalid UTF-8 byte (<<#{char}>>).") +end diff --git a/Elixir/mix.exs b/Elixir/mix.exs new file mode 100644 index 0000000..b1c02da --- /dev/null +++ b/Elixir/mix.exs @@ -0,0 +1,29 @@ +defmodule Rsv.MixProject do + use Mix.Project + + def project do + [ + app: :rsv, + version: "0.1.0", + elixir: "~> 1.16", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + {:jason, "~> 1.4", only: :test} + ] + end +end diff --git a/Elixir/mix.lock b/Elixir/mix.lock new file mode 100644 index 0000000..ddb949c --- /dev/null +++ b/Elixir/mix.lock @@ -0,0 +1,3 @@ +%{ + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, +} diff --git a/Elixir/test/rsv_test.exs b/Elixir/test/rsv_test.exs new file mode 100644 index 0000000..c339611 --- /dev/null +++ b/Elixir/test/rsv_test.exs @@ -0,0 +1,53 @@ +defmodule RSVTest do + use ExUnit.Case, async: true + + doctest RSV + + @fixture_dir Path.relative_to_cwd("../TestFiles/") + + describe "valid input" do + setup context do + # Get the current file name based on the test name by stripping + # the prefix in the test name generated by ExUnit ("test valids + # ...") + file_name = String.replace_prefix("#{context.test}", "test #{context.describe} ", "") + root = Path.rootname(file_name) + + with {:ok, data} <- File.read("#{root}.rsv"), + {:ok, expectation_data} <- File.read("#{root}.json"), + {:ok, expectation} <- Jason.decode(expectation_data) do + {:ok, raw: data, expectation: expectation} + end + end + + for test_name <- Path.wildcard(Path.expand("Valid*.rsv", @fixture_dir)) do + test test_name, context do + # First we check if we match the expected result (from the + # json file) if we decode the raw data + assert context.expectation == RSV.decode!(context.raw) + # Then we attempt to decode and then re-encode; our encoder + # should produce the same data as the given input + assert context.raw == context.raw |> RSV.decode!() |> RSV.encode!() + end + end + end + + describe "invalid input" do + setup context do + # Get the current file name based on the test name by stripping + # the prefix in the test name generated by ExUnit ("test valids + # ...") + file_name = String.replace_prefix("#{context.test}", "test #{context.describe} ", "") + + {:ok, raw: File.read!(file_name)} + end + + for test_name <- Path.wildcard(Path.expand("Invalid*.rsv", @fixture_dir)) do + test test_name, context do + assert_raise RSV.ParseError, fn -> + _should_raise = RSV.decode!(context.raw) + end + end + end + end +end diff --git a/Elixir/test/test_helper.exs b/Elixir/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/Elixir/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()