defmodule Kuddle.Path do
@moduledoc """
Utility module for looking up nodes in a document.
Usage:
nodes = Kuddle.select(document, path)
[{:node, "node", attrs, children}] = Kuddle.select(document, ["node"])
"""
alias Kuddle.Value
alias Kuddle.Node
@typedoc """
A Kuddle document is a list of nodes, nothing fancy.
"""
@type document :: Kuddle.Decoder.document()
@typedoc """
A single node in a document
"""
@type document_node :: Kuddle.Decoder.document_node()
@typedoc """
Node names are strings
"""
@type node_name :: String.t()
@typedoc """
An attribute key (i.e. %Value{}) can be anything, normally it will be an id or string though
"""
@type attr_key :: any()
@typedoc """
An attribute value can be anything
"""
@type attr_value :: any()
@type attribute_path :: {:attr, attr_key()}
| {:attr, attr_key(), attr_value()}
| {:value, attr_value()}
@typedoc """
In addition to the attribute_path, node attributes can also use shorthands for
`{:attr, key, value}` and `{:value, value}`, as `{key, value}` and `value` respectively.
"""
@type node_attributes :: [attribute_path() | {any(), any()} | any()]
@typedoc """
Any single path selector
"""
@type selector :: node_name()
| attribute_path()
| {:node, node_name()}
| {:node, node_name(), node_attributes()}
@typedoc """
A path is a list of selectors that should be used when matching against the document.
It allows different fragments which can be used to match different properties of the node.
Fragments:
* `node_name` - the node name can be passed as a plain string in the path to select a node based on its name
Example:
[%Kuddle.Node{name: "node"}] = Kuddle.select(document, ["node"])
[] = Kuddle.select(document, ["doesnt_exist"])
* `{:attr, key}` - a node with an attribute key can be looked up as well, this will ignore the
value and only look for key value pairs with the key
Example:
[%Kuddle.Node{attributes: [{%{value: "id"}, _value}]}] = Kuddle.select(document, [{:attr, "id"}])
[] = Kuddle.select(document, [{:attr, "cid"}])
* `{:attr, key, value}` - an attribute of key and value can be looked up as well
Example:
[%Kuddle.Node{attributes: [{%{value: "id"}, %{value: "egg"}}]}] = Kuddle.select(document, [{:attr, "id", "egg"}])
[] = Kuddle.select(document, [{:attr, "cid", "8847"}])
* `{:value, value}` - for nodes with normal values, the loose value can be looked up as well
Example:
[%Kuddle.Node{attributes: [%{value: 1}]}] = Kuddle.select(document, [{:value, 1}])
[] = Kuddle.select(document, [{:value, 2}])
* `{:node, node_name}` - equivalent to just providing the `node_name`
Example:
[%Kuddle.Node{name: "node"}] = Kuddle.select(document, [{:node, "node"}])
[] = Kuddle.select(document, [{:node, "doesnt_exist"}])
* `{:node, node_name, attrs}` - lookup a node with attributes
Example:
[%Kuddle.Node{name: "node", attributes: [1]}] = Kuddle.select(document, [{:node, "node", [1]}])
[%Kuddle.Node{name: "node", attributes: [1]}] = Kuddle.select(document, [{:node, "node", [{:value, 1}]}])
[%Kuddle.Node{name: "node2", attributes: [{%{value: "id"}, _value}]}] = Kuddle.select(document, [{:node, "node2", [{:attr, "id"}]}])
[%Kuddle.Node{name: "node3", attributes: [{%{value: "id"}, %{value: "bacon"}}]}] = Kuddle.select(document, [{:node, "node3", [{:attr, "id", "bacon"}]}])
[%Kuddle.Node{name: "node3", attributes: [{%{value: "id"}, %{value: "bacon"}}]}] = Kuddle.select(document, [{:node, "node3", [{"id", "bacon"}]}])
[] = Kuddle.select(document, [{:node, "node3", [{"id", "fries"}]}])
"""
@type path :: [selector()]
@doc """
Select nodes from the given kuddle document, see the path type for the supported selectors
Args:
* `document` - the document to lookup, or nil
* `path` - the selectors to use when looking up the nodes
* `acc` - the current accumulator, defaults to an empty list
"""
@spec select(nil | document(), path(), list()) :: document()
def select(document, path, acc \\ [])
def select(nil, _, acc) do
Enum.reverse(acc)
end
def select([], [], acc) do
Enum.reverse(acc)
end
def select([item | rest], [] = path, acc) do
select(rest, path, [item | acc])
end
def select([%Node{children: children} = node | rest], [expected | _path] = path, acc) do
acc =
if match_node?(node, expected) do
[node | acc]
else
acc
end
acc = select(children, [expected], acc)
select(rest, path, acc)
end
def select([], [_expected | path], acc) do
select(Enum.reverse(acc), path, [])
end
@spec match_node?(document_node(), selector()) :: boolean()
defp match_node?(%Node{attributes: attrs}, {:attr, _key} = attr) do
Enum.any?(attrs, &match_attr?(&1, attr))
end
defp match_node?(%Node{attributes: attrs}, {:attr, _key, _value} = attr) do
Enum.any?(attrs, &match_attr?(&1, attr))
end
defp match_node?(%Node{name: name}, {:node, name}) do
true
end
defp match_node?(%Node{name: name} = node, {:node, name, expected_attrs}) do
Enum.all?(expected_attrs, fn
{:attr, _} = attr ->
match_node?(node, attr)
{:attr, _, _} = attr ->
match_node?(node, attr)
{:value, _} = attr ->
match_node?(node, attr)
{key, value} ->
match_node?(node, {:attr, key, value})
value ->
match_node?(node, {:value, value})
end)
end
defp match_node?(%Node{attributes: attrs}, {:value, _value} = attr) do
Enum.any?(attrs, &match_attr?(&1, attr))
end
defp match_node?(%Node{name: name}, name) do
true
end
defp match_node?(%Node{}, _) do
false
end
defp match_attr?(%Value{}, {:attr, _key}) do
false
end
defp match_attr?(%Value{}, {:attr, _key, _value}) do
false
end
defp match_attr?(%Value{value: value}, {:value, value}) do
true
end
defp match_attr?(%Value{}, {:value, _}) do
false
end
defp match_attr?({_key, _value}, {:value, _}) do
false
end
defp match_attr?({%Value{value: key}, _value}, {:attr, expected_key}) do
key == expected_key
end
defp match_attr?({%Value{value: key}, %Value{value: value}}, {:attr, expected_key, expected_value}) do
key == expected_key and
value == expected_value
end
end