Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Elixir macro expansion problems, but only in a comprehension

Tags:

macros

elixir

As part of our Dev Book Club at work, I wrote a random password generator in Elixir. Decided to play with metaprogramming, and write it with macros to DRY things up a bit.

This works perfectly:

# lib/macros.ex
defmodule Macros do
  defmacro define_alphabet(name, chars) do
    len = String.length(chars) - 1

    quote do
      def unquote(:"choose_#{name}")(chosen, 0) do
        chosen
      end

      def unquote(:"choose_#{name}")(chosen, n) do
        alphabet = unquote(chars) 

        unquote(:"choose_#{name}")([(alphabet |> String.at :random.uniform(unquote(len))) | chosen], n - 1)
      end
    end
  end
end

# lib/generate_password.ex
defmodule GeneratePassword do
  require Macros

  Macros.define_alphabet :alpha, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
  Macros.define_alphabet :special,  "~`!@#$%^&*?"
  Macros.define_alphabet :digits, "0123456789"

  def generate_password(min_length, n_special, n_digits) do
    []
    |> choose_alpha(min_length - n_special - n_digits)
    |> choose_special(n_special)
    |> choose_digits(n_digits)
    |> Enum.shuffle
    |> Enum.join
  end
end

I'd like to define the alphabets in a Dict/map, or even a list, and iterate over that to call Macros.define_alphabet, rather than calling it 3 times manually. However, when I try this, using the code below, it fails compilation, no matter what structure I use to hold the alphabets.

alphabets = %{
  alpha: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
  special:  "~`!@#$%^&*?",
  digits: "0123456789",
}

for {name, chars} <- alphabets, do: Macros.define_alphabet(name, chars)

Giving the following error:

Erlang/OTP 18 [erts-7.1] [source] [64-bit] [smp:8:8] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Compiled lib/macros.ex

== Compilation error on file lib/generate_password.ex ==
** (FunctionClauseError) no function clause matching in String.Graphemes.next_grapheme_size/1
    (elixir) unicode/unicode.ex:231: String.Graphemes.next_grapheme_size({:chars, [line: 24], nil})
    (elixir) unicode/unicode.ex:382: String.Graphemes.length/1
    expanding macro: Macros.define_alphabet/2
    lib/generate_password.ex:24: GeneratePassword (module)
    (elixir) lib/kernel/parallel_compiler.ex:100: anonymous fn/4 in Kernel.ParallelCompiler.spawn_compilers/8

I've tried having the alphabets map as a list of lists, list of tuples, a map of atoms->strings and strings->strings, and it doesn't seem to matter. I've also tried piping the pairs into Enum.each instead of using the "for" comprehension, like so:

alphabets |> Enum.each fn {name, chars} -> Macros.define_alphabet(name, chars) end

All of them give the same results. Thought it might be something to do with calling :random.uniform, and changed that to:

alphabet |> to_char_list |> Enum.shuffle |> Enum.take(1) |> to_string

That just changes the error slightly, to:

Erlang/OTP 18 [erts-7.1] [source] [64-bit] [smp:8:8] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]


== Compilation error on file lib/generate_password.ex ==
** (Protocol.UndefinedError) protocol String.Chars not implemented for {:name, [line: 24], nil}
    (elixir) lib/string/chars.ex:3: String.Chars.impl_for!/1
    (elixir) lib/string/chars.ex:17: String.Chars.to_string/1
    expanding macro: Macros.define_alphabet/2
    lib/generate_password.ex:24: GeneratePassword (module)
    (elixir) lib/kernel/parallel_compiler.ex:100: anonymous fn/4 in Kernel.ParallelCompiler.spawn_compilers/8

Even with that change, works fine when I manually call Macros.define_alphabet like at the top, but not when I do it in any kind of comprehension or using Enum.each.

It's not a huge deal, but I'd like to be able to programmatically add to and remove from the list of alphabets depending on a user-defined configuration.

I'm sure as I get further into Metaprogramming Elixir, I'll be able to figure this out, but if anyone has any suggestions, I'd appreciate it.

like image 385
Chris Doggett Avatar asked Dec 15 '15 21:12

Chris Doggett


2 Answers

List comprehensions is a way to consume one list and get another list (or Enumerable in general case) from it. In your case you don't want to get a new list, you want to define functions in module. So, list comprehensions isn't appropriate way to do it.

You could use yet another macros to define alphabets from a map.

like image 141
Roman Smirnov Avatar answered Oct 05 '22 22:10

Roman Smirnov


Figured it out. Works either way if I pass the bind_quoted list to quote, though I haven't found a way to pre-calculate the length and use :random.uniform like I was before, to avoid having to do the whole list conversion for every character choice.

# lib/macros.ex
defmodule Macros do
  defmacro define_alphabet(name, chars) do
    quote bind_quoted: [name: name, chars: chars] do
      def unquote(:"choose_#{name}")(chosen, 0) do
        chosen
      end

      def unquote(:"choose_#{name}")(chosen, n) do
        unquote(:"choose_#{name}")([(unquote(chars) |> to_char_list |> Enum.shuffle |> Enum.take(1) |> to_string) | chosen], n - 1)
      end
    end
  end
end

And now I can call it any way I like:

# lib/generate_password.ex
defmodule GeneratePassword do
  require Macros

  alphabets = [
    alpha: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
    special:  "~`!@#$%^&*?",
    digits: "0123456789",
  ] 

  for {name, chars} <- alphabets do
    Macros.define_alphabet name, chars
  end

  # or alphabets |> Enum.map fn {name, chars} -> Macros.define_alphabet name, chars end
  # or Macros.define_alphabet :alpha2, "abcd1234"

  def generate_password(min_length, n_special, n_digits) do
    []
    |> choose_alpha(min_length - n_special - n_digits)
    |> choose_special(n_special)
    |> choose_digits(n_digits)
    |> Enum.shuffle
    |> Enum.join
  end
end

EDIT Better answer after 4 more years experience and reading Metaprogramming Elixir. I pre-split the alphabets using String.graphemes/1 and use Enum.random/1, the latter of which I don't think existed 4 years ago.

defmodule ChooseFrom do
  defmacro __using__(_options) do
    quote do
      import unquote(__MODULE__)
    end
  end

  defmacro alphabet(name, chars) when is_binary(chars) do
    function_name = :"choose_#{name}"

    quote do
      defp unquote(function_name)(remaining) when is_integer(remaining) and remaining > 0 do
        unquote(function_name)([], remaining)
      end

      defp unquote(function_name)(chosen, remaining) when is_integer(remaining) and remaining > 0 do
        next_char = Enum.random(unquote(String.graphemes(chars)))

        unquote(function_name)([next_char | chosen], remaining - 1)
      end
      defp unquote(function_name)(chosen, _), do: chosen
    end
  end
end

defmodule PasswordGenerator do
  use ChooseFrom

  alphabet(:alpha, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
  alphabet(:digits, "0123456789")
  alphabet(:special, "~`!@#$%^&*?")

  def generate_password(min_length, num_special, num_digits) do
    num_alpha = min_length - num_special - num_digits

    num_alpha
    |> choose_alpha()
    |> choose_special(num_special)
    |> choose_digits(num_digits)
    |> Enum.shuffle()
    |> Enum.join()
  end
end

Output:

iex> 1..20 |> Enum.map(fn _ -> PasswordGenerator.generate_password(20, 3, 3) end)
["01?dZQRhrHAbmP*vF3I@", "UUl3O0vqS^S3CQDr^AC$", "%1NOF&Xyh3Cgped*5xnk",
 "Scg$oDVUB8Vx&b72GB^R", "SnYN?hlc*D03bW~5Rmsf", "R5Yg6Zr^Jm^!BOCD8Jjm",
 "ni^Cg9BBQDne0v`M`2fj", "L8@$TpIUdEN1uy5h@Rel", "6MjrJyiuB26qntl&M%$L",
 "$9hTsDh*y0La?hdhXn7I", "6rq8jeTH%ko^FLMX$g6a", "7jVDS#tjh0GS@q#RodN6",
 "dOBi1?4LW%lrr#wG2LIu", "S*Zcuhg~R4!fBoij7y2o", "M!thW*g2Ta&M7o7MpscI",
 "r5n3$tId^OWX^KGzjl4v", "L2CLJv&&YwncF6JY*5Zw", "DJWT`f6^3scwCO4pQQ*Q",
 "mm2jVh5!J!Zalsuxk8&o", "O#kqGRfHGnu042PS`O*A"]
like image 23
Chris Doggett Avatar answered Oct 05 '22 23:10

Chris Doggett