defmodule ExToon.Decoder.Expand do @moduledoc false # Path expansion — converts dotted keys like "a.b.c" into nested objects. # # Only expands when ALL dot-separated segments are valid unquoted identifiers # matching ^[A-Za-z_][A-Za-z0-9_]*$ (no dots within a segment). # Keys that do not match are left unchanged. # Keys that were originally quoted (tagged as {:toon_literal, k}) are never expanded. # # expand_ordered/2 operates on {:toon_ordered_object, pairs} structures produced by # EventBuilder.build_for_expand/1, preserving document order for correct LWW semantics. # When two keys would conflict: # - strict=true → raises ExToon.DecodeError # - strict=false → LWW: the later key (in document order) wins alias ExToon.DecodeError # Expand path: ordered, conflict-aware entry point. # Called from Core.decode_lines when expand_paths == :safe. @spec expand_ordered(term(), keyword()) :: term() def expand_ordered({:toon_ordered_object, pairs}, opts) do strict = Keyword.get(opts, :strict, true) Enum.reduce(pairs, %{}, fn {key, raw_value}, acc -> value = expand_ordered(raw_value, opts) case key do {:toon_literal, k} -> # Quoted key: treat as a literal string, never expand. overwrite_merge(acc, k, value, strict) k when is_binary(k) -> segments = String.split(k, ".") if length(segments) > 1 and Enum.all?(segments, &valid_segment?/1) do nested = build_nested(segments, value) conflict_merge(acc, nested, strict) else overwrite_merge(acc, k, value, strict) end end end) end def expand_ordered(list, opts) when is_list(list) do Enum.map(list, fn item -> expand_ordered(item, opts) end) end def expand_ordered(v, _opts), do: v # --------------------------------------------------------------------------- # Legacy entry point (for expand_paths=:safe without order metadata). # Only used when EventBuilder.build (not build_for_expand) was called. # --------------------------------------------------------------------------- @spec expand(term()) :: term() def expand(map) when is_map(map) do Enum.reduce(map, %{}, fn {key, value}, acc -> expanded_value = expand(value) segments = String.split(key, ".") if length(segments) > 1 and Enum.all?(segments, &valid_segment?/1) do nested = build_nested(segments, expanded_value) deep_merge(acc, nested) else Map.put(acc, key, expanded_value) end end) end def expand(list) when is_list(list), do: Enum.map(list, &expand/1) def expand(v), do: v # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- # A valid segment is a bare identifier — no dots, starts with letter or underscore defp valid_segment?(seg), do: Regex.match?(~r/^[A-Za-z_][A-Za-z0-9_]*$/, seg) defp build_nested([key], value), do: %{key => value} defp build_nested([key | rest], value), do: %{key => build_nested(rest, value)} # Merge for non-expansion keys: new value (LWW — document order is preserved in the # reduce loop, so later keys naturally overwrite earlier ones via Map.put). defp overwrite_merge(acc, key, value, strict) do case Map.fetch(acc, key) do {:ok, existing} when is_map(existing) and not is_map(value) -> if strict do throw({:decode_error, %DecodeError{ reason: :expansion_conflict, message: "path expansion conflict: key #{inspect(key)} is both an object and a primitive" }}) else Map.put(acc, key, value) end {:ok, existing} when not is_map(existing) and is_map(value) -> if strict do throw({:decode_error, %DecodeError{ reason: :expansion_conflict, message: "path expansion conflict: key #{inspect(key)} is both a primitive and an object" }}) else Map.put(acc, key, value) end _ -> Map.put(acc, key, value) end end # Merge for expansion keys: perform deep merge, detecting conflicts. defp conflict_merge(acc, new_map, strict) do Map.merge(acc, new_map, fn key, v1, v2 -> cond do is_map(v1) and is_map(v2) -> conflict_merge(v1, v2, strict) is_map(v1) and not is_map(v2) -> if strict do throw({:decode_error, %DecodeError{ reason: :expansion_conflict, message: "path expansion conflict at key #{inspect(key)}: object vs primitive" }}) else v2 end not is_map(v1) and is_map(v2) -> if strict do throw({:decode_error, %DecodeError{ reason: :expansion_conflict, message: "path expansion conflict at key #{inspect(key)}: primitive vs object" }}) else v2 end true -> v2 end end) end # Simple deep merge (used by the legacy expand/1 path). defp deep_merge(map1, map2) do Map.merge(map1, map2, fn _key, v1, v2 -> if is_map(v1) and is_map(v2) do deep_merge(v1, v2) else v2 end end) end end