defmodule Microprints.Microprint do @moduledoc """ Pure functions for generating microprint visualizations from source files. A microprint is a compact visual representation where each line of code becomes a colored stripe based on syntax elements. """ @type segment :: %{start: non_neg_integer(), length: non_neg_integer(), color: String.t()} @type line_info :: %{ color: String.t(), indent: non_neg_integer(), length: non_neg_integer(), segments: [segment()] | nil } @type microprint :: %{lines: [line_info()], line_count: non_neg_integer()} # Color palette for different syntax elements @colors %{ # purple - def, defmodule, if, case, etc. keyword: "#8B5CF6", # green - strings and charlists string: "#10B981", # gray - comments comment: "#6B7280", # red - function definitions function_def: "#EF4444", # amber - atoms atom: "#F59E0B", # blue - numbers number: "#3B82F6", # pink - module names module: "#EC4899", # purple - operators operator: "#8B5CF6", # light gray - default default: "#D1D5DB" } @elixir_keywords ~w(def defp defmodule defmacro defmacrop defguard defguardp defstruct defexception defprotocol defimpl defdelegate defoverridable if unless case cond with for receive try catch rescue after raise throw import require alias use quote unquote fn do end when and or not in) @doc """ Generates a microprint for the given file path. Returns `{:ok, microprint}` or `{:error, reason}`. """ @spec generate(String.t()) :: {:ok, microprint()} | {:error, term()} def generate(path) do case File.read(path) do {:ok, content} -> lines = String.split(content, "\n") line_infos = analyze_lines(path, lines) {:ok, %{lines: line_infos, line_count: length(line_infos)}} {:error, reason} -> {:error, reason} end end @doc """ Returns the color for a given syntax type. """ @spec color_for(atom()) :: String.t() def color_for(type), do: Map.get(@colors, type, @colors.default) @doc """ Returns the color legend as a list of {label, color} tuples. """ @spec color_legend() :: [{String.t(), String.t()}] def color_legend do [ {"Function", @colors.function_def}, {"Module", @colors.module}, {"Keyword", @colors.keyword}, {"String", @colors.string}, {"Comment", @colors.comment}, {"Atom", @colors.atom}, {"Number", @colors.number}, {"Other", @colors.default} ] end # Private functions defp analyze_lines(path, lines) do if elixir_file?(path) do analyze_elixir_lines(lines) else analyze_generic_lines(lines) end end defp analyze_elixir_lines(lines) do lines |> Enum.with_index(1) |> Enum.map(fn {line, line_num} -> segments = tokenize_elixir_line(line, line_num) %{ color: colorize_elixir_line(line), indent: count_indent(line), length: content_length(line), segments: segments } end) end defp analyze_generic_lines(lines) do Enum.map(lines, fn line -> %{ color: colorize_generic_line(line), indent: count_indent(line), length: content_length(line), segments: nil } end) end defp content_length(line) do # Length of line after trimming leading whitespace line |> String.trim_leading() |> String.length() end defp count_indent(line) do # Count leading spaces (treat tab as 2 spaces) line |> String.graphemes() |> Enum.take_while(&(&1 in [" ", "\t"])) |> Enum.reduce(0, fn char, acc -> case char do " " -> acc + 1 "\t" -> acc + 2 end end) end defp elixir_file?(path) do ext = Path.extname(path) ext in [".ex", ".exs"] end defp tokenize_elixir_line(line, line_num) do # Handle empty or whitespace-only lines trimmed = String.trim(line) if trimmed == "" do nil else case :elixir_tokenizer.tokenize(String.to_charlist(line), line_num, []) do {:ok, _end_line, _end_col, _warnings, tokens, _terminators} -> build_segments(line, Enum.reverse(tokens)) {:error, _, _, _, _} -> # Tokenization failed, fall back to nil (use single color) nil end end end defp build_segments(line, tokens) do line_length = String.length(line) # Convert tokens to segments with positions segments = tokens |> Enum.map(&token_to_segment/1) |> Enum.reject(&is_nil/1) # Fill gaps with default color fill_gaps(segments, line_length) end defp token_to_segment({type, {_line, col, token_chars}, _value}) when is_list(token_chars) do # col is 1-indexed, convert to 0-indexed start = col - 1 length = length(token_chars) color = token_type_to_color(type) %{start: start, length: length, color: color} end defp token_to_segment({type, {_line, col, _}, value}) when is_atom(value) do start = col - 1 length = value |> Atom.to_string() |> String.length() color = token_type_to_color(type) %{start: start, length: length, color: color} end defp token_to_segment({type, {_line, col, nil}, _value}) do # Single-character tokens like (, ), etc. start = col - 1 color = token_type_to_color(type) %{start: start, length: 1, color: color} end defp token_to_segment({type, {_line, col, length}, _value}) when is_integer(length) do start = col - 1 color = token_type_to_color(type) %{start: start, length: max(1, length), color: color} end defp token_to_segment(_), do: nil defp token_type_to_color(type) do case type do # Keywords t when t in [:do, :end, :fn, :when, :and, :or, :not, :in] -> @colors.keyword # Identifiers that are keywords :identifier -> @colors.default :kw_identifier -> @colors.keyword :paren_identifier -> @colors.function_def :do_identifier -> @colors.function_def # Atoms :atom -> @colors.atom :kw_identifier_unsafe -> @colors.atom # Strings :bin_string -> @colors.string :list_string -> @colors.string :bin_heredoc -> @colors.string :list_heredoc -> @colors.string :interpolation_start -> @colors.string :interpolation_end -> @colors.string # Numbers :int -> @colors.number :flt -> @colors.number # Operators t when t in [ :dual_op, :mult_op, :power_op, :concat_op, :range_op, :xor_op, :pipe_op, :match_op, :assoc_op, :arrow_op, :rel_op, :comp_op, :and_op, :or_op, :not_op, :capture_op, :ternary_op, :at_op, :unary_op, :type_op, :stab_op ] -> @colors.operator # Module aliases :alias -> @colors.module # Comments are handled separately (not tokenized) # Punctuation - use default _ -> @colors.default end end defp fill_gaps(segments, line_length) do # Sort by start position sorted = Enum.sort_by(segments, & &1.start) # Build list with gaps filled {filled, last_end} = Enum.reduce(sorted, {[], 0}, fn segment, {acc, current_pos} -> # Add gap segment if needed acc = if segment.start > current_pos do gap = %{ start: current_pos, length: segment.start - current_pos, color: @colors.default } [gap | acc] else acc end segment_end = segment.start + segment.length {[segment | acc], max(current_pos, segment_end)} end) # Add trailing gap if needed filled = if last_end < line_length do gap = %{start: last_end, length: line_length - last_end, color: @colors.default} [gap | filled] else filled end Enum.reverse(filled) end defp colorize_elixir_line(line) do trimmed = String.trim(line) cond do # Empty line trimmed == "" -> @colors.default # Comment line String.starts_with?(trimmed, "#") -> @colors.comment # Function definition Regex.match?(~r/^\s*(def|defp|defmacro|defmacrop|defguard|defguardp)\s+\w+/, line) -> @colors.function_def # Module definition Regex.match?(~r/^\s*defmodule\s+/, line) -> @colors.module # String line (starts with quote or contains prominent string) Regex.match?(~r/^\s*["']/, line) || Regex.match?(~r/~[a-zA-Z]\[|~[a-zA-Z]{/, line) -> @colors.string # Doc attribute Regex.match?(~r/^\s*@(moduledoc|doc|spec|type|typep|typedoc)/, line) -> @colors.comment # Keyword-heavy line keyword_line?(trimmed) -> @colors.keyword # Atom-heavy line Regex.match?(~r/^\s*:[\w_]+/, line) || Regex.match?(~r/@\w+/, line) -> @colors.atom # Default true -> dominant_color(line) end end defp keyword_line?(trimmed) do first_word = trimmed |> String.split(~r/[\s(]/, parts: 2) |> List.first() || "" first_word in @elixir_keywords end defp dominant_color(line) do cond do # Has numbers Regex.match?(~r/\b\d+\.?\d*\b/, line) && !Regex.match?(~r/[a-zA-Z]/, line) -> @colors.number # Has operators Regex.match?(~r/[|><+\-*\/=]+/, line) -> @colors.operator # Has function calls (word followed by parenthesis) Regex.match?(~r/\w+\(/, line) -> @colors.default true -> @colors.default end end defp colorize_generic_line(line) do trimmed = String.trim(line) cond do trimmed == "" -> @colors.default # Common comment patterns String.starts_with?(trimmed, "//") || String.starts_with?(trimmed, "#") || String.starts_with?(trimmed, "/*") || String.starts_with?(trimmed, "*") -> @colors.comment # String-heavy line Regex.match?(~r/["'`]/, line) -> @colors.string # Keywords for various languages Regex.match?( ~r/\b(function|const|let|var|class|import|export|return|if|else|for|while)\b/, line ) -> @colors.keyword true -> @colors.default end end end