defmodule Keywords do @moduledoc """ Documentation for `KeywordParser`. """ alias Keywords.Pattern alias Keywords.PatternSupervisor @doc """ Generates new keyword-pattern for parsing strings from a list of keywords. opts -> case_sensitive: true/false defaults -> case_sensitive: false ## Examples iex> Keywords.new_pattern(:stocks, ["TSLA", "XOM", "AMZN"]) {:ok, :stocks} iex> Keywords.new_pattern(:stocks, ["TSLA", "XOM", "AMZN"]) {:error, :already_started} """ @new_pattern_defaults %{case_sensitive: false} def new_pattern(name, keyword_list, opts \\ []) def new_pattern(:all, _, _), do: {:error, :reserved_pattern_name} def new_pattern(nil, _, _), do: {:error, :invalid_pattern_name} def new_pattern(_, nil, _), do: {:error, :invalid_keywords} # {:error, :pattern_not_found} def new_pattern(name, keyword_list, opts) when is_list(keyword_list) do opts = Enum.into(opts, @new_pattern_defaults) pattern = keyword_list |> add_case_variants(opts) |> :binary.compile_pattern() keywords_map = Enum.into(keyword_list, %{}, fn kw -> {String.downcase(kw), kw} end) data = %{ pattern: pattern, keywords_map: keywords_map, options: opts } registry_name = via_registry_tuple(name) case DynamicSupervisor.start_child(PatternSupervisor, {Pattern, [registry_name, data]}) do {:ok, _pid} -> {:ok, name} {:error, {:already_started, _pid}} -> {:error, :already_started} err -> err end end @doc """ Removes pattern by name. ## Examples iex> Keywords.kill_pattern(:stocks) {:ok, :stocks} iex> Keywords.kill_pattern(:stocks) {:error, :not_found} """ def kill_pattern(name) do result = Registry.lookup(PatternRegistry, name) |> List.first() with {pid, _} <- result, :ok <- DynamicSupervisor.terminate_child(PatternSupervisor, pid) do {:ok, name} else _err -> {:error, :pattern_not_found} end end @doc """ Parses tickers from string opts -> counts: true/false (include total occurrences of each keyword) -> aggregate: true/false (group results by pattern name) defaults -> counts: false, aggregate: true ## Examples iex> Keywords.parse(" XOM AAPL $TSLA buy now, ++ PLTR and $AMZN", :stocks) [XOM, AAPL, TSLA, PLTR, AMZN] iex> Keywords.parse(" XOM AAPL $TSLA buy now, ++ PLTR and $AMZN", :stocks_2) ["AAPL", "PLTR"] iex> Keywords.parse(" XOM AAPL $TSLA buy now, ++ PLTR and $AMZN", [:stocks, :stocks_2]) ["AAPL", "PLTR", XOM, TSLA, PLTR, AMZN] iex> Keywords.parse(" XOM AAPL AMZN $TSLA buy now, ++ PLTR and $AMZN", :all) ["XOM", "AMZN", "TSLA", "AAPL", "PLTR"] iex> Keywords.parse(" XOM AAPL AMZN $TSLA buy now, ++ PLTR and $AMZN", :stocks, [counts: true]) [{"AMZN", 2}, {"TSLA", 1}, {"XOM", 1}] iex> Keywords.parse(" XOM AAPL AMZN $TSLA buy now, ++ PLTR and $AMZN", [:stocks, :stocks_2], [counts: true]) [{"AAPL", 1}, {"AMZN", 2}, {"PLTR", 1}, {"TSLA", 1}, {"XOM", 1}] iex> Keywords.parse(" XOM AAPL AMZN $TSLA buy now, ++ PLTR and $AMZN", [:stocks, :stocks_2], [counts: true, aggregate: false]) [stocks: [{"AMZN", 2}, {"TSLA", 1}, {"XOM", 1}], stocks_2: [{"AAPL", 1}, {"PLTR", 1}]] iex> Keywords.parse(" XOM AAPL AMZN $TSLA buy now, ++ PLTR and $AMZN", [:stocks, :stocks_2], [aggregate: false]) [stocks: ["XOM", "AMZN", "TSLA"], stocks_2: ["AAPL", "PLTR"]] iex> Keywords.parse("a|n[xwn;qw%dl$qm*w", :stocks) [] iex> Keywords.parse(nil, :stocks) [] """ @parse_defaults %{counts: false, aggregate: true} def parse(string, pattern_names, opts \\ []) def parse(nil, _, _), do: {:ok, []} def parse(_, nil, _), do: {:error, :pattern_not_found} def parse(string, pattern_names, opts) when is_list(pattern_names) do opts = Enum.into(opts, @parse_defaults) result_sets = pattern_names |> Enum.map(fn name -> name end) # via registry_tuple... |> Enum.map(fn name -> get_matches(name, string) end) |> Enum.reduce({[], []}, fn {:ok, {name, result}}, {results, errors} -> {results ++ [{name, result}], errors} {:error, name}, {results, errors} -> {results, errors ++ [name]} end) case result_sets do {results, []} -> matches = process_multi_pattern_opts(results, opts) {:ok, matches} {_results, missing_patterns} -> {:error, %{patterns_not_found: missing_patterns}} end end def parse(string, :all, opts) do pattern_names = Registry.select(PatternRegistry, [{{:"$1", :_, :_}, [], [:"$1"]}]) parse(string, pattern_names, opts) end def parse(string, pattern_name, opts) do opts = Enum.into(opts, @parse_defaults) case get_matches(pattern_name, string) do {:ok, {_name, result}} -> matches = process_single_pattern_opts(result, opts) {:ok, matches} {:error, name} -> {:error, %{patterns_not_found: name}} end end # @doc false # def recompile_pattern(pid, keyword_list) do # :binary.compile_pattern(keyword_list) # Pattern.recompile_pattern(pid, keyword_list) # end defp process_single_pattern_opts(result, opts) do case opts do %{counts: true} -> result |> Enum.frequencies() |> Map.to_list() %{counts: false} -> Enum.uniq(result) end end defp process_multi_pattern_opts(result_sets, opts) do case opts do %{counts: true, aggregate: true} -> result_sets |> Enum.flat_map(fn {_k, set} -> set end) |> Enum.frequencies() |> Map.to_list() %{counts: false, aggregate: true} -> result_sets |> Enum.flat_map(fn {_k, set} -> set end) |> Enum.uniq() %{counts: true, aggregate: false} -> result_sets |> Enum.map(fn {k, set} -> {k, Enum.frequencies(set) |> Map.to_list()} end) %{counts: false, aggregate: false} -> result_sets |> Enum.map(fn {k, set} -> {k, Enum.uniq(set)} end) end end defp via_registry_tuple(name), do: {:via, Registry, {PatternRegistry, name}} defp from_registry_tuple({:via, _, {_, name}}), do: name # TODO: prevent substring matches! defp get_matches(name, string) do case Registry.lookup(PatternRegistry, name) do [{pid, _}] -> %{pattern: pattern, keywords_map: keywords_map, options: opts} = Pattern.get(pid) result = string |> :binary.matches(pattern) |> Enum.map(fn bit_match -> pull_keyword_match(string, bit_match, keywords_map, opts) end) {:ok, {name, result}} [] -> {:error, name} end end defp pull_keyword_match(string, bit_match, keywords_map, opts) do match = :binary.part(string, bit_match) case opts.case_sensitive do false -> keywords_map[String.downcase(match)] _ -> match end end # EXAMPLE INPUTS # string = " XOM AAPL AMZN $TSLA buy now, ++ PLTR and $AMZN" # args for 'bit_match' and 'match' # {1, 3} # "XOM" # {10, 4} # "AMZN" # {16, 4} # "TSLA" # {43, 4} # "AMZN" defp remove_substring_matches(bit_match, match, string) do # BEFORE OR AFTER # - space # - punctuation/symbols . , ! ? # $ % ^ & @ ( ) > < / \ | [ ] { } ~ * - + = : ; " ' ` # BEFORE # - start of string # AFTER # - end of string end defp add_case_variants(keyword_list, %{case_sensitive: true}), do: keyword_list defp add_case_variants(keyword_list, %{case_sensitive: false}) do uppercase_keyword_list = Enum.map(keyword_list, fn kw -> String.upcase(kw) end) lowercase_keyword_list = Enum.map(keyword_list, fn kw -> String.downcase(kw) end) capitalized_keyword_list = Enum.map(lowercase_keyword_list, fn kw -> String.capitalize(kw) end) uppercase_keyword_list ++ lowercase_keyword_list ++ capitalized_keyword_list end end