429 lines
10 KiB
Elixir
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
|