defmodule Mix.Tasks.Jetons.Inspect do @moduledoc """ Inspect and debug design tokens. Provides four modes for exploring token files and resolver documents. ## Token Lookup mix jetons.inspect -f tokens.resolver.json --token color.background.brand.default mix jetons.inspect -f tokens.resolver.json --token color.background.brand.default --set brand=markant ## Reference Tracing mix jetons.inspect -f tokens.resolver.json --refs button.primary.color-background.default ## List Permutations mix jetons.inspect -f tokens.resolver.json --permutations ## Diff Contexts # Diff defaults vs a specific context mix jetons.inspect -f tokens.resolver.json --diff --set theme=dark # Diff two explicit contexts mix jetons.inspect -f tokens.resolver.json --diff --set brand=acme --vs brand=markant ## Options * `-f` / `--file` — (required) input JSON or resolver file * `--token` — token dot-path to look up across contexts * `--refs` — token dot-path for reference chain tracing * `--permutations` — list all valid modifier combinations * `--diff` — diff resolved tokens between two contexts * `--set` — modifier input for context selection (format: `name=value`, repeatable) * `--vs` — second context for diff comparison (format: `name=value`, repeatable) """ use Mix.Task alias Jetons.DTCG alias Jetons.Parser alias Jetons.Ref alias Jetons.Resolver @shortdoc "Inspect and debug design tokens" @switches [ file: :string, token: :string, refs: :string, permutations: :boolean, diff: :boolean, set: [:string, :keep], vs: [:string, :keep] ] @aliases [f: :file] @impl Mix.Task def run(args) do {opts, _rest} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) path = opts[:file] || raise Mix.Error, "--file (-f) is required" mode = detect_mode(opts) doc = load_doc(path) is_resolver = resolver_file?(path) base_dir = Path.dirname(Path.expand(path)) run_mode(mode, doc, is_resolver, base_dir, opts) end defp detect_mode(opts) do modes = [ {:token, opts[:token]}, {:refs, opts[:refs]}, {:permutations, opts[:permutations]}, {:diff, opts[:diff]} ] |> Enum.filter(fn {_, v} -> v end) case modes do [{mode, _}] -> validate_mode_opts(mode, opts) [] -> raise Mix.Error, "Specify one of: --token, --refs, --permutations, --diff" _ -> raise Mix.Error, "Only one mode allowed at a time (--token, --refs, --permutations, --diff)" end end defp validate_mode_opts(mode, opts) do if Keyword.has_key?(opts, :vs) and mode != :diff do raise Mix.Error, "--vs can only be used with --diff" end mode end defp run_mode(:token, doc, is_resolver, base_dir, opts), do: run_token(doc, is_resolver, base_dir, opts) defp run_mode(:refs, doc, is_resolver, base_dir, opts), do: run_refs(doc, is_resolver, base_dir, opts) defp run_mode(:permutations, doc, is_resolver, _base_dir, _opts), do: run_permutations(doc, is_resolver) defp run_mode(:diff, doc, is_resolver, base_dir, opts), do: run_diff(doc, is_resolver, base_dir, opts) # --- Token Lookup --- defp run_token(doc, true = _is_resolver, base_dir, opts) do token_path = opts[:token] set_input = parse_set_opts(opts) perms = filter_permutations(doc, set_input) if perms == [] do raise Mix.Error, "No permutations match the given --set values" end results = Enum.map(perms, fn input -> config = Resolver.resolve!(doc, input, base_dir: base_dir) raw = config |> Parser.from_config(resolve_refs: false) |> Map.new() resolved = case Parser.from_config_safe(config) do {:ok, tokens} -> tokens |> Map.new() |> Map.get(token_path) {:error, _reason} -> nil end {input, Map.get(raw, token_path), resolved} end) if Enum.all?(results, fn {_, raw, _} -> is_nil(raw) end) do config = Resolver.resolve!(doc, elem(hd(results), 0), base_dir: base_dir) raise_token_not_found(token_path, config, base_dir) end type = lookup_type(doc, hd(perms), base_dir, token_path) print_token_header(token_path, type) Enum.each(results, fn {input, raw, resolved} -> label = format_input(input) display = format_token_value(raw, resolved) Mix.shell().info(" #{String.pad_trailing(label, 40)} #{display}") end) end defp run_token(doc, false = _is_resolver, _base_dir, opts) do token_path = opts[:token] tokens = doc |> Parser.from_config() |> Map.new() case Map.fetch(tokens, token_path) do {:ok, value} -> type = DTCG.type_map(doc) |> Map.get(token_path) print_token_header(token_path, type) Mix.shell().info(" #{format_value(value)}") :error -> raise_token_not_found(token_path, doc, nil) end end # --- Reference Tracing --- defp run_refs(doc, is_resolver, base_dir, opts) do token_path = opts[:refs] config = resolve_config(doc, is_resolver, base_dir, opts) unresolved = config |> Parser.from_config(resolve_refs: false) |> Map.new() case Map.fetch(unresolved, token_path) do {:ok, _} -> chain = trace_refs(token_path, unresolved) print_ref_chain(chain) :error -> raise_token_not_found(token_path, config, base_dir) end end defp trace_refs(token_path, unresolved, visited \\ MapSet.new()) do if MapSet.member?(visited, token_path) do [{token_path, :cycle}] else value = Map.get(unresolved, token_path) visited = MapSet.put(visited, token_path) cond do is_nil(value) -> [{token_path, :not_found}] Ref.ref?(value) -> ref_path = Ref.path(value) [{token_path, value} | trace_refs(ref_path, unresolved, visited)] is_binary(value) and String.contains?(value, "{") -> [{token_path, {:embedded, value}}] true -> [{token_path, {:literal, value}}] end end end defp print_ref_chain(chain) do chain |> Enum.with_index() |> Enum.each(fn {{path, value}, depth} -> indent = String.duplicate(" ", depth) prefix = if depth == 0, do: "", else: "#{indent}\u2514\u2500 " case value do :cycle -> Mix.shell().info("#{prefix}#{path} (circular reference!)") :not_found -> Mix.shell().info("#{prefix}#{path} (not found!)") {:embedded, raw} -> Mix.shell().info("#{prefix}#{path}") Mix.shell().info("#{indent} = #{raw} (embedded references)") {:literal, raw} -> Mix.shell().info(format_literal(path, raw, depth, prefix)) raw when is_binary(raw) -> Mix.shell().info("#{prefix}#{path}") end end) end # --- Permutations --- defp run_permutations(doc, true = _is_resolver) do modifiers = Map.get(doc, "modifiers", %{}) perms = Resolver.list_permutations(doc) Mix.shell().info("Modifiers:") modifiers |> Enum.sort_by(fn {name, _} -> name end) |> Enum.each(fn {name, mod_def} -> Mix.shell().info(" #{name}: #{format_modifier_contexts(mod_def)}") end) Mix.shell().info("\nPermutations (#{length(perms)}):") Enum.each(perms, fn input -> Mix.shell().info(" #{format_input(input)}") end) end defp run_permutations(_doc, false = _is_resolver) do raise Mix.Error, "--permutations requires a .resolver.json file" end # --- Diff --- defp run_diff(doc, true = _is_resolver, base_dir, opts) do set_input = parse_set_opts(opts) vs_input = parse_vs_opts(opts) {left_input, right_input, left_label, right_label} = build_diff_inputs(doc, set_input, vs_input) left_tokens = resolve_and_flatten_raw(doc, left_input, base_dir) right_tokens = resolve_and_flatten_raw(doc, right_input, base_dir) all_paths = MapSet.union(MapSet.new(Map.keys(left_tokens)), MapSet.new(Map.keys(right_tokens))) changes = all_paths |> Enum.sort() |> Enum.filter(fn path -> Map.get(left_tokens, path) != Map.get(right_tokens, path) end) |> Enum.map(fn path -> {path, Map.get(left_tokens, path), Map.get(right_tokens, path)} end) Mix.shell().info("Diff: #{left_label} \u2192 #{right_label}") Mix.shell().info("#{length(changes)} token(s) changed\n") if changes == [] do Mix.shell().info(" (no differences)") else max_path_len = changes |> Enum.map(fn {p, _, _} -> String.length(p) end) |> Enum.max() Enum.each(changes, fn {path, left, right} -> Mix.shell().info( " #{String.pad_trailing(path, max_path_len)} #{format_value(left)} \u2192 #{format_value(right)}" ) end) end end defp run_diff(_doc, false = _is_resolver, _base_dir, _opts), do: raise(Mix.Error, "--diff requires a .resolver.json file") defp build_diff_inputs(doc, set_input, vs_input) do modifiers = Map.get(doc, "modifiers", %{}) defaults = Resolver.default_input(modifiers) cond do vs_input != %{} -> left = Map.merge(defaults, set_input) right = Map.merge(defaults, vs_input) {left, right, format_input(left), format_input(right)} set_input != %{} -> left = defaults right = Map.merge(defaults, set_input) {left, right, format_input(left), format_input(right)} true -> raise Mix.Error, "--diff requires at least --set to specify what to compare" end end # --- Shared Helpers --- defp load_doc(path) do case File.read(path) do {:ok, contents} -> case Jason.decode(contents) do {:ok, data} -> data {:error, e} -> raise Mix.Error, "Invalid JSON in #{path}: #{Exception.message(e)}" end {:error, reason} -> raise Mix.Error, "Cannot read file #{path}: #{:file.format_error(reason)}" end end defp resolver_file?(path), do: String.ends_with?(path, ".resolver.json") defp resolve_config(doc, true = _is_resolver, base_dir, opts) do input = parse_set_opts(opts) modifiers = Map.get(doc, "modifiers", %{}) full_input = Map.merge(Resolver.default_input(modifiers), input) Resolver.resolve!(doc, full_input, base_dir: base_dir) end defp resolve_config(doc, false = _is_resolver, _base_dir, _opts), do: doc defp resolve_and_flatten_raw(doc, input, base_dir) do doc |> Resolver.resolve!(input, base_dir: base_dir) |> Parser.from_config(resolve_refs: false) |> Map.new() end defp filter_permutations(doc, set_input) do modifiers = Map.get(doc, "modifiers", %{}) Resolver.list_permutations(doc) |> Enum.filter(fn perm -> Enum.all?(set_input, fn {k, v} -> perm[k] == v end) end) |> case do [] when map_size(set_input) > 0 -> # If set doesn't match any permutation, it may specify all modifiers defaults = Resolver.default_input(modifiers) [Map.merge(defaults, set_input)] perms -> perms end end defp lookup_type(doc, input, base_dir, token_path) do doc |> Resolver.resolve!(input, base_dir: base_dir) |> DTCG.type_map() |> Map.get(token_path) rescue ArgumentError -> nil end defp raise_token_not_found(token_path, config, _base_dir) do all_paths = config |> Parser.from_config(resolve_refs: false) |> Enum.map(&elem(&1, 0)) suggestion = Ref.closest(token_path, all_paths) message = case suggestion do nil -> "Token not found: #{token_path}" match -> "Token not found: #{token_path}. Did you mean #{inspect(match)}?" end raise Mix.Error, message end defp parse_set_opts(opts) do opts |> Keyword.get_values(:set) |> parse_kv_pairs("--set") end defp parse_vs_opts(opts) do opts |> Keyword.get_values(:vs) |> parse_kv_pairs("--vs") end defp parse_kv_pairs(values, flag_name) do Enum.reduce(values, %{}, fn value, acc -> case String.split(value, "=", parts: 2) do [key, val] -> Map.put(acc, key, val) _ -> raise Mix.Error, "Invalid #{flag_name} format: #{inspect(value)}. Expected: name=value" end end) end defp format_input(input) do input |> Enum.sort_by(fn {k, _} -> k end) |> Enum.map_join(", ", fn {k, v} -> "#{k}=#{v}" end) end defp format_modifier_contexts(mod_def) do contexts = mod_def["contexts"] |> Map.keys() |> Enum.sort() default = mod_def["default"] Enum.map_join(contexts, ", ", fn ctx -> if ctx == default, do: "#{ctx} (default)", else: ctx end) end defp format_literal(path, raw, 0, _prefix), do: "#{path} = #{format_value(raw)}" defp format_literal(_path, raw, _depth, prefix), do: "#{prefix}#{format_value(raw)}" defp format_value(nil), do: "(not defined)" defp format_value(v) when is_binary(v), do: v defp format_value(v), do: inspect(v) defp format_token_value(nil, _), do: "(not defined)" defp format_token_value(raw, resolved) when is_binary(raw) and is_binary(resolved) do if raw == resolved, do: raw, else: "#{resolved} (via #{raw})" end defp format_token_value(raw, nil) when is_binary(raw), do: "#{raw} (unresolved)" defp format_token_value(raw, resolved), do: "#{format_value(resolved)} (via #{format_value(raw)})" defp print_token_header(token_path, nil) do Mix.shell().info("Token: #{token_path}\n") end defp print_token_header(token_path, type) do Mix.shell().info("Token: #{token_path}") Mix.shell().info("Type: #{type}\n") end end