Extracted from qwan-tracker
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
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
|
||||
Reference in New Issue
Block a user