microprints-phoenix/lib/microprint.ex

429 lines
10 KiB
Elixir

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