Packages

Parser XSLT y motor de cadena original para CFDI

Current section

Files

Jump to
cfdi_transform lib cfdi transform cadena_engine.ex
Raw

lib/cfdi/transform/cadena_engine.ex

defmodule Cfdi.Transform.CadenaEngine do
@moduledoc """
Motor de generación de cadena original. Espejo de
[`cadena-engine.ts`](https://github.com/MisaelMa/node-cfdi/blob/main/packages/cfdi/transform/src/cadena-engine.ts).
Recibe el XML del CFDI y un `template_registry` parseado por
`Cfdi.Transform.XsltParser`, y produce la cadena `|...|...||`.
"""
alias Cfdi.Transform.Types
@doc """
Colapsa whitespace (`\\s+` → ` `) y hace `trim`. Equivale a `normalize-space()`
de XSLT, que es lo que usan las plantillas `Requerido` y `Opcional`.
"""
@spec normalize_space(String.t() | nil) :: String.t()
def normalize_space(nil), do: ""
def normalize_space(s) when is_binary(s) do
s
|> String.replace(~r/\s+/u, " ")
|> String.trim()
end
@doc "Equivalente a `Requerido(valor)`: prefija `|` + `normalize-space`."
@spec requerido(String.t() | nil) :: String.t()
def requerido(value), do: "|" <> normalize_space(value || "")
@doc "Equivalente a `Opcional(valor)`: vacío si nil, sino `|` + `normalize-space`."
@spec opcional(String.t() | nil) :: String.t()
def opcional(nil), do: ""
def opcional(value), do: "|" <> normalize_space(value)
@doc """
Genera la cadena original a partir del contenido XML y un registro de
plantillas. Devuelve `{:ok, cadena}` o un `{:error, _}`.
"""
@spec generate_cadena_original(String.t(), Types.template_registry()) ::
{:ok, String.t()} | {:error, term()}
def generate_cadena_original(xml_content, registry) when is_binary(xml_content) do
case Saxy.SimpleForm.parse_string(strip_bom(xml_content)) do
{:ok, root} ->
cadena =
case find_root_element([root], registry) do
nil ->
"||||"
root_match ->
if namespaces_match?(root_match, registry) do
"|" <> IO.iodata_to_binary(process_node(root_match, registry)) <> "||"
else
"|||"
end
end
{:ok, cadena}
{:error, reason} ->
{:error, reason}
end
end
defp find_root_element(elements, registry) do
Enum.find_value(elements, fn
{tag, _attrs, children} = el ->
if Map.has_key?(registry.templates, to_string(tag)) do
el
else
find_root_element(child_elements(children), registry)
end
_ ->
nil
end)
end
defp child_elements(children) do
Enum.filter(children, &match?({_, _, _}, &1))
end
defp namespaces_match?({_tag, attrs, _children}, registry) do
if map_size(registry.namespaces) == 0 do
true
else
Enum.all?(registry.namespaces, fn {prefix, xslt_uri} ->
case attr(attrs, "xmlns:" <> prefix) do
nil -> true
xml_uri -> xml_uri == xslt_uri
end
end)
end
end
@doc """
Procesa un nodo aplicando la plantilla correspondiente a su nombre completo
(con prefijo). Devuelve iodata.
"""
@spec process_node(term(), Types.template_registry()) :: iodata()
def process_node({tag, _attrs, _children} = node, registry) do
case Map.get(registry.templates, to_string(tag)) do
nil ->
[]
%{rules: rules} ->
Enum.map(rules, fn rule -> apply_rule(node, rule, registry) end)
end
end
def process_node(_, _), do: []
defp apply_rule(node, %{type: :attr} = rule, _registry) do
{_t, attrs, _} = node
value = attr(attrs, rule.name)
if rule.required, do: requerido(value), else: opcional(value)
end
defp apply_rule(node, %{type: :text} = rule, _registry) do
value =
cond do
rule.select == "." ->
text_content(node)
true ->
case resolve_select(node, rule.select) do
[] -> nil
[first | _] -> text_content(first)
end
end
if rule.required, do: requerido(value), else: opcional(value)
end
defp apply_rule(node, %{type: :child} = rule, registry) do
if condition_skips?(node, rule) do
[]
else
cond do
Map.get(rule, :wildcard, false) ->
process_wildcard(node, registry)
true ->
elements =
if Map.get(rule, :descendant, false) do
resolve_descendant(node, last_segment(rule.select))
else
resolve_select(node, rule.select)
end
process_child_elements(elements, rule, registry)
end
end
end
defp condition_skips?(node, %{condition: cond_select}) when is_binary(cond_select) do
resolve_select(node, cond_select) == []
end
defp condition_skips?(_node, _rule), do: false
defp process_child_elements(elements, rule, registry) do
cond do
rule.for_each or elements != [] ->
Enum.map(elements, fn el ->
inline_part =
Enum.map(rule.inline, fn inline_rule ->
apply_rule(el, inline_rule, registry)
end)
tail =
if rule.apply_templates do
process_node(el, registry)
else
[]
end
[inline_part, tail]
end)
true ->
[]
end
end
defp process_wildcard({_t, _a, children}, registry) do
children
|> child_elements()
|> Enum.map(&process_node(&1, registry))
end
@doc """
Resuelve un `select` con segmentos `prefix:Element` separados por `/`.
Devuelve la lista de nodos coincidentes (paridad con `resolveSelect` de Node).
"""
@spec resolve_select(term(), String.t()) :: [term()]
def resolve_select({_t, _a, children} = _node, select) do
clean = String.replace(select, ~r/^\.\//, "")
segments = String.split(clean, "/", trim: true)
walk_segments([{nil, [], child_elements(children)}], segments)
end
defp walk_segments(current, []) do
current
|> Enum.flat_map(fn {tag, attrs, children} -> [{tag, attrs, children}] end)
|> Enum.reject(&is_nil/1)
end
defp walk_segments(current, [segment | rest]) do
next =
Enum.flat_map(current, fn
nil ->
[]
{_tag, _attrs, children} ->
children
|> child_elements()
|> Enum.filter(fn {child_tag, _, _} -> to_string(child_tag) == segment end)
end)
walk_segments(next, rest)
end
defp resolve_descendant(node, name) do
results = []
collect_descendants(node, name, results)
end
defp collect_descendants({_t, _a, children}, name, acc) do
Enum.reduce(child_elements(children), acc, fn {child_tag, _, _} = child, a ->
a =
if to_string(child_tag) == name do
a ++ [child]
else
a
end
collect_descendants(child, name, a)
end)
end
defp last_segment(path) do
path
|> String.split("/")
|> List.last()
end
defp text_content({_t, _a, children}) do
parts =
Enum.flat_map(children, fn
s when is_binary(s) -> [s]
_ -> []
end)
case parts do
[] -> nil
_ -> Enum.join(parts, "")
end
end
defp text_content(_), do: nil
defp attr(attrs, name) do
case List.keyfind(attrs, name, 0) do
{_, v} -> v
nil -> nil
end
end
# Saxy rechaza el BOM UTF-8 al inicio del documento.
defp strip_bom(<<0xEF, 0xBB, 0xBF, rest::binary>>), do: rest
defp strip_bom(content), do: content
end