Current section
Files
Jump to
Current section
Files
lib/keywords.ex
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