Packages

Compile-time design token library for Elixir

Current section

Files

Jump to
jetons lib mix tasks jetons.inspect.ex
Raw

lib/mix/tasks/jetons.inspect.ex

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