diff --git a/lib/hashids.ex b/lib/hashids.ex index 205adbd..c28ce36 100644 --- a/lib/hashids.ex +++ b/lib/hashids.ex @@ -10,23 +10,25 @@ defmodule Hashids do """ - defstruct [ - alphabet: [], salt: [], min_len: 0, - a_len: 0, seps: [], s_len: 0, guards: [], g_len: 0, - ] + defstruct alphabet: [], salt: [], min_len: 0, a_len: 0, seps: [], s_len: 0, guards: [], g_len: 0 @typep t :: %Hashids{ - alphabet: charlist, salt: charlist, min_len: non_neg_integer, - a_len: non_neg_integer, seps: charlist, s_len: non_neg_integer, guards: charlist, - g_len: non_neg_integer, - } + alphabet: charlist, + salt: charlist, + min_len: non_neg_integer, + a_len: non_neg_integer, + seps: charlist, + s_len: non_neg_integer, + guards: charlist, + g_len: non_neg_integer + } @min_alphabet_len 16 @sep_div 3.5 @guard_div 12 - @default_alphabet 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' - @seps 'cfhistuCFHISTU' + @default_alphabet ~c"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + @seps ~c"cfhistuCFHISTU" alias Hashids.Helpers @@ -57,17 +59,26 @@ defmodule Hashids do alphabet = Helpers.consistent_shuffle(alphabet, salt) guard_count = trunc(Float.ceil(a_len / @guard_div)) - {alphabet, guards, seps, a_len} = if a_len < 3 do - {guards, seps} = Enum.split(seps, guard_count) - {alphabet, guards, seps, a_len} - else - {guards, alphabet} = Enum.split(alphabet, guard_count) - a_len = a_len - guard_count - {alphabet, guards, seps, a_len} - end + + {alphabet, guards, seps, a_len} = + if a_len < 3 do + {guards, seps} = Enum.split(seps, guard_count) + {alphabet, guards, seps, a_len} + else + {guards, alphabet} = Enum.split(alphabet, guard_count) + a_len = a_len - guard_count + {alphabet, guards, seps, a_len} + end + %Hashids{ - alphabet: alphabet, salt: salt, min_len: min_len, - a_len: a_len, seps: seps, s_len: length(seps), guards: guards, g_len: length(guards), + alphabet: alphabet, + salt: salt, + min_len: min_len, + a_len: a_len, + seps: seps, + s_len: length(seps), + guards: guards, + g_len: length(guards) } end @@ -84,29 +95,39 @@ defmodule Hashids do end def encode(s, numbers) when is_list(numbers) do - {num_checksum, _} = Enum.reduce(numbers, {0, 100}, fn - num, _ when num < 0 or not is_integer(num) -> - raise Hashids.Error, message: "Expected a non-negative integer. Got: #{inspect num}" - num, {cksm, i} -> - {cksm + rem(num, i), i+1} - end) + {num_checksum, _} = + Enum.reduce(numbers, {0, 100}, fn + num, _ when num < 0 or not is_integer(num) -> + raise Hashids.Error, message: "Expected a non-negative integer. Got: #{inspect(num)}" + + num, {cksm, i} -> + {cksm + rem(num, i), i + 1} + end) %Hashids{ - alphabet: alphabet, salt: salt, min_len: min_len, - a_len: a_len, seps: seps, s_len: s_len, guards: guards, g_len: g_len, + alphabet: alphabet, + salt: salt, + min_len: min_len, + a_len: a_len, + seps: seps, + s_len: s_len, + guards: guards, + g_len: g_len } = s lottery = Enum.at(alphabet, rem(num_checksum, a_len)) + {precipher, p_len, alphabet} = - preencode(numbers, 0, [lottery], 1, [lottery|salt], alphabet, a_len, seps, s_len) + preencode(numbers, 0, [lottery], 1, [lottery | salt], alphabet, a_len, seps, s_len) {interm_cipher, i_len} = extend_precipher1(precipher, p_len, min_len, num_checksum, guards, g_len) + {interm_cipher, i_len} = extend_precipher2(interm_cipher, i_len, min_len, num_checksum, guards, g_len) extend_cipher(interm_cipher, i_len, min_len, alphabet, a_len) - |> List.to_string + |> List.to_string() end @doc """ @@ -115,26 +136,32 @@ defmodule Hashids do @spec decode(t, iodata) :: {:ok, [non_neg_integer]} | {:error, :invalid_input_data} def decode( - %Hashids{alphabet: alphabet, salt: salt, a_len: a_len, seps: seps, guards: guards}, - data) when is_list(data) or is_binary(data) - do + %Hashids{alphabet: alphabet, salt: salt, a_len: a_len, seps: seps, guards: guards}, + data + ) + when is_list(data) or is_binary(data) do try do cipher_split_at_guards = String.split(IO.iodata_to_binary(data), Enum.map(guards, &<<&1::utf8>>)) - cipher_part = case cipher_split_at_guards do - [_, x] -> x - [_, x, _] -> x - [x|_] -> x - end - result = if cipher_part != "" do - {<>, rest_part} = String.split_at(cipher_part, 1) - rkey = [lottery|salt] - String.split(rest_part, Enum.map(seps, &<<&1::utf8>>)) - |> decode_parts(rkey, alphabet, a_len, []) - else - [] - end + cipher_part = + case cipher_split_at_guards do + [_, x] -> x + [_, x, _] -> x + [x | _] -> x + end + + result = + if cipher_part != "" do + {<>, rest_part} = String.split_at(cipher_part, 1) + rkey = [lottery | salt] + + String.split(rest_part, Enum.map(seps, &<<&1::utf8>>)) + |> decode_parts(rkey, alphabet, a_len, []) + else + [] + end + {:ok, result} rescue _error -> {:error, :invalid_input_data} @@ -165,27 +192,33 @@ defmodule Hashids do # defp uniquify_chars(charlist) do - uniquify_chars(charlist, [], MapSet.new, 0) + uniquify_chars(charlist, [], MapSet.new(), 0) end defp uniquify_chars([], acc, set, nchars), do: {Enum.reverse(acc), set, nchars} - defp uniquify_chars([char|rest], acc, set, nchars) do + defp uniquify_chars([char | rest], acc, set, nchars) do if MapSet.member?(set, char) do uniquify_chars(rest, acc, set, nchars) else - uniquify_chars(rest, [char|acc], MapSet.put(set, char), nchars+1) + uniquify_chars(rest, [char | acc], MapSet.put(set, char), nchars + 1) end end defp parse_option!(:alphabet, kw) do - list = case Keyword.fetch(kw, :alphabet) do - :error -> @default_alphabet - {:ok, bin} when is_binary(bin) -> String.to_charlist(bin) - _ -> - message = "Alphabet has to be a string of at least 16 characters/codepoints." - raise Hashids.Error, message: message - end + list = + case Keyword.fetch(kw, :alphabet) do + :error -> + @default_alphabet + + {:ok, bin} when is_binary(bin) -> + String.to_charlist(bin) + + _ -> + message = "Alphabet has to be a string of at least 16 characters/codepoints." + raise Hashids.Error, message: message + end + {uniq_alphabet, set, nchars} = uniquify_chars(list) :ok = validate_alphabet!(set, nchars) {uniq_alphabet, nchars} @@ -218,7 +251,8 @@ defmodule Hashids do msg = "Spaces in the alphabet are not allowed." raise Hashids.Error, message: msg - true -> :ok + true -> + :ok end end @@ -226,8 +260,10 @@ defmodule Hashids do {seps, alphabet, a_len} = filter_seps(seps, [], alphabet, a_len) seps = Helpers.consistent_shuffle(seps, salt) s_len = length(seps) + if s_len == 0 or a_len / s_len > @sep_div do new_len = max(2, trunc(Float.ceil(a_len / @sep_div))) + if new_len > s_len do diff = new_len - s_len {left, right} = Enum.split(alphabet, diff) @@ -248,28 +284,27 @@ defmodule Hashids do {Enum.reverse(seps), alphabet, a_len} end - defp filter_seps([char|rest], seps, alphabet, a_len) do + defp filter_seps([char | rest], seps, alphabet, a_len) do if j = Enum.find_index(alphabet, &(&1 == char)) do # alphabet should not contains seps - {left, [_|right]} = Enum.split(alphabet, j) + {left, [_ | right]} = Enum.split(alphabet, j) new_alphabet = left ++ right - filter_seps(rest, [char|seps], new_alphabet, a_len-1) + filter_seps(rest, [char | seps], new_alphabet, a_len - 1) else # seps should contain only characters present in alphabet filter_seps(rest, seps, alphabet, a_len) end end - defp preencode([num], _, inret, p_len, rkey, alphabet, a_len, _, _) do {outret, step_len, new_alphabet, _} = preencode_step(num, inret, rkey, alphabet, a_len) - {outret, p_len+step_len, new_alphabet} + {outret, p_len + step_len, new_alphabet} end - defp preencode([num|rest], i, inret, p_len, rkey, alphabet, a_len, seps, seps_len) do + defp preencode([num | rest], i, inret, p_len, rkey, alphabet, a_len, seps, seps_len) do {outret, step_len, new_alphabet, last} = preencode_step(num, inret, rkey, alphabet, a_len) ret = seps_step(last, i, num, outret, seps, seps_len) - preencode(rest, i+1, ret, p_len+step_len+1, rkey, new_alphabet, a_len, seps, seps_len) + preencode(rest, i + 1, ret, p_len + step_len + 1, rkey, new_alphabet, a_len, seps, seps_len) end defp preencode_step(num, ret, rkey, alphabet, a_len) do @@ -279,35 +314,33 @@ defmodule Hashids do {[ret | last], last_len, enc_alphabet, last} end - defp seps_step([char|_], i, num, ret, seps, seps_len) do - index = rem(num, char+i) |> rem(seps_len) + defp seps_step([char | _], i, num, ret, seps, seps_len) do + index = rem(num, char + i) |> rem(seps_len) [ret, Enum.at(seps, index)] end - defp extend_precipher1(precipher, p_len, min_len, num_cksm, guards, g_len) - when p_len < min_len - do + when p_len < min_len do char = nested_list_at(precipher, 0) index = rem(num_cksm + char, g_len) guard = Enum.at(guards, index) - {[guard|precipher], p_len+1} + {[guard | precipher], p_len + 1} end + defp extend_precipher1(precipher, p_len, _, _, _, _), do: {precipher, p_len} defp extend_precipher2(precipher, p_len, min_len, num_cksm, guards, g_len) - when p_len < min_len - do + when p_len < min_len do char2 = nested_list_at(precipher, 2) index = rem(num_cksm + char2, g_len) guard = Enum.at(guards, index) - {[precipher, guard], p_len+1} + {[precipher, guard], p_len + 1} end + defp extend_precipher2(precipher, p_len, _, _, _, _), do: {precipher, p_len} defp extend_cipher(cipher, c_len, min_len, alphabet, a_len) - when c_len < min_len - do + when c_len < min_len do new_alphabet = Helpers.consistent_shuffle(alphabet, alphabet) half_len = div(a_len, 2) {left, right} = Enum.split(new_alphabet, half_len) @@ -316,8 +349,9 @@ defmodule Hashids do new_c_len = c_len + a_len excess = new_c_len - min_len + if excess > 0 do - new_cipher |> List.flatten |> Enum.drop(div(excess, 2)) |> Enum.take(min_len) + new_cipher |> List.flatten() |> Enum.drop(div(excess, 2)) |> Enum.take(min_len) else extend_cipher(new_cipher, new_c_len, min_len, new_alphabet, a_len) end @@ -325,17 +359,15 @@ defmodule Hashids do defp extend_cipher(cipher, _, _, _, _), do: cipher - defp decode_parts([], _, _, _, acc), do: Enum.reverse(acc) - defp decode_parts([part|rest], rkey, alphabet, a_len, acc) do + defp decode_parts([part | rest], rkey, alphabet, a_len, acc) do buffer = rkey ++ alphabet dec_alphabet = Helpers.consistent_shuffle(alphabet, Enum.take(buffer, a_len)) number = Helpers.decode(String.to_charlist(part), dec_alphabet, a_len) - decode_parts(rest, rkey, dec_alphabet, a_len, [number|acc]) + decode_parts(rest, rkey, dec_alphabet, a_len, [number | acc]) end - defp nested_list_at(list, i) when is_integer(i) and i >= 0 do try do do_nested_list_at(list, i) @@ -345,17 +377,17 @@ defmodule Hashids do end end - defp do_nested_list_at([h|rest], i) when is_list(h) do + defp do_nested_list_at([h | rest], i) when is_list(h) do new_i = do_nested_list_at(h, i) do_nested_list_at(rest, new_i) end - defp do_nested_list_at([h|_], 0) do - throw h + defp do_nested_list_at([h | _], 0) do + throw(h) end - defp do_nested_list_at([_|rest], i) do - do_nested_list_at(rest, i-1) + defp do_nested_list_at([_ | rest], i) do + do_nested_list_at(rest, i - 1) end defp do_nested_list_at([], i) do diff --git a/lib/hashids/helpers.ex b/lib/hashids/helpers.ex index 4bc8da6..368141d 100644 --- a/lib/hashids/helpers.ex +++ b/lib/hashids/helpers.ex @@ -8,25 +8,24 @@ defmodule Hashids.Helpers do def consistent_shuffle(list, []), do: list def consistent_shuffle(list, key) do - loop(length(list)-1, 0, 0, list, key, length(key)) + loop(length(list) - 1, 0, 0, list, key, length(key)) end defp loop(0, _, _, list, _, _), do: list defp loop(i, v, p, list, key, key_len) do key_char = Enum.at(key, v) - j = rem(2*key_char + v + p, i) + j = rem(2 * key_char + v + p, i) - loop(i-1, rem(v+1, key_len), p+key_char, swap(list, j, i), key, key_len) + loop(i - 1, rem(v + 1, key_len), p + key_char, swap(list, j, i), key, key_len) end defp swap(list, i, j) do - {first, [i_elem|rest]} = Enum.split(list, i) - {second, [j_elem|tail]} = Enum.split(rest, j-i-1) - List.flatten([first, j_elem, second], [i_elem|tail]) + {first, [i_elem | rest]} = Enum.split(list, i) + {second, [j_elem | tail]} = Enum.split(rest, j - i - 1) + List.flatten([first, j_elem, second], [i_elem | tail]) end - # Builds up a string encoding of the numbers using characters from the # alphabet def encode(num, alphabet, a_len) do @@ -37,10 +36,9 @@ defmodule Hashids.Helpers do defp encode(num, alphabet, a_len, acc, len, _) do new_acc = [Enum.at(alphabet, rem(num, a_len)) | acc] - encode(div(num, a_len), alphabet, a_len, new_acc, len+1, true) + encode(div(num, a_len), alphabet, a_len, new_acc, len + 1, true) end - # Decodes the string back into a number def decode(str, alphabet, a_len) do decode(0, str, length(str), alphabet, a_len) @@ -48,10 +46,10 @@ defmodule Hashids.Helpers do defp decode(num, [], 0, _, _), do: num - defp decode(num, [char|rest], s_len, alphabet, a_len) do + defp decode(num, [char | rest], s_len, alphabet, a_len) do pos = Enum.find_index(alphabet, &(&1 == char)) - rem_len = s_len-1 - new_num = num+pos*ipow(a_len, rem_len) + rem_len = s_len - 1 + new_num = num + pos * ipow(a_len, rem_len) decode(new_num, rest, rem_len, alphabet, a_len) end @@ -66,6 +64,6 @@ defmodule Hashids.Helpers do end defp ipow(a, n) do - a * ipow(a, n-1) + a * ipow(a, n - 1) end end diff --git a/test/hashids_test.exs b/test/hashids_test.exs index 21b04d2..1e804d4 100644 --- a/test/hashids_test.exs +++ b/test/hashids_test.exs @@ -3,26 +3,26 @@ defmodule HashidsTest.Encode do import HashidsTest.Helpers - testcase_from_fixture "default_salt" - testcase_from_fixture "default_salt_list" - testcase_from_fixture "min_length_3" - testcase_from_fixture "min_length_20" - testcase_from_fixture "custom_salt_1" - testcase_from_fixture "custom_salt_2" - testcase_from_fixture "short_alphabet" - testcase_from_fixture "custom_alphabet" - testcase_from_fixture "long_alphabet" - testcase_from_fixture "mix_and_match" + testcase_from_fixture("default_salt") + testcase_from_fixture("default_salt_list") + testcase_from_fixture("min_length_3") + testcase_from_fixture("min_length_20") + testcase_from_fixture("custom_salt_1") + testcase_from_fixture("custom_salt_2") + testcase_from_fixture("short_alphabet") + testcase_from_fixture("custom_alphabet") + testcase_from_fixture("long_alphabet") + testcase_from_fixture("mix_and_match") - testcase_from_fixture_large "default_salt_large" - testcase_from_fixture_large "custom_salt_large" - testcase_from_fixture_large "min_length_20_large" - testcase_from_fixture_large "short_alphabet_large" - testcase_from_fixture_large "long_alphabet_large" + testcase_from_fixture_large("default_salt_large") + testcase_from_fixture_large("custom_salt_large") + testcase_from_fixture_large("min_length_20_large") + testcase_from_fixture_large("short_alphabet_large") + testcase_from_fixture_large("long_alphabet_large") test "decode! fail throws exception" do - assert_raise Hashids.DecodingError, fn-> - Hashids.decode!(Hashids.new,"%%%%%") + assert_raise Hashids.DecodingError, fn -> + Hashids.decode!(Hashids.new(), "%%%%%") end end end diff --git a/test/test_helper.exs b/test/test_helper.exs index c11e3be..83ebd1a 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -17,8 +17,9 @@ defmodule HashidsTest.Helpers do quote do test unquote(name) do s = Hashids.new(unquote(options)) + for {nums, encoded} <- unquote(tests) do - assert encoded == Hashids.encode(s, nums) |> IO.iodata_to_binary + assert encoded == Hashids.encode(s, nums) |> IO.iodata_to_binary() assert List.wrap(nums) === Hashids.decode!(s, encoded) end end @@ -29,10 +30,13 @@ defmodule HashidsTest.Helpers do defp parse_test_case(str) do [header, rest] = String.split(str, "\n", parts: 2) - options = case Regex.run(@header_re, header) do - [_, salt, min_len, alphabet, _] -> build_opts(salt, min_len, alphabet) - [_, salt, min_len, alphabet] -> build_opts(salt, min_len, alphabet) - end + + options = + case Regex.run(@header_re, header) do + [_, salt, min_len, alphabet, _] -> build_opts(salt, min_len, alphabet) + [_, salt, min_len, alphabet] -> build_opts(salt, min_len, alphabet) + end + tests = tests_from_string(rest) {options, tests} end @@ -48,7 +52,7 @@ defmodule HashidsTest.Helpers do |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) - |> Enum.reject(&match?("#"<>_, &1)) + |> Enum.reject(&match?("#" <> _, &1)) |> Enum.map(&split_fields/1) end @@ -62,6 +66,7 @@ defmodule HashidsTest.Helpers do |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) |> Enum.map(&String.to_integer/1) + {numbers, encoded} _ ->