Extracted from qwan-tracker

This commit is contained in:
Willem van den Ende 2026-05-12 15:58:07 +01:00
commit e06832fb13
16 changed files with 117311 additions and 0 deletions

6
.formatter.exs Normal file
View File

@ -0,0 +1,6 @@
# Used by "mix format"
[
import_deps: [:phoenix],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["{mix,lib,test}/**/*.{ex,exs}"]
]

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Dependencies
deps/
_build/
# Generated files
*.ez
beam/
# OS files
.DS_Store
# Mix
*.rbe

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Willem
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

101
README.md Normal file
View File

@ -0,0 +1,101 @@
# Microprints
Compact visual representations of source code files.
## What is a microprint?
A microprint is a compact visual fingerprint where each line of code becomes
a colored stripe based on its syntax elements. The result is a small,
informative visualization that shows the structure and composition of a file
at a glance.
## Features
- **Syntax-aware colorization** — Uses the Erlang/Elixir tokenizer for accurate
token classification (functions, strings, comments, atoms, etc.)
- **Multi-language support** — Works with Elixir, Erlang, and falls back to
regex-based detection for other languages
- **Indentation-aware** — Preserves indentation levels to show nesting structure
- **SVG rendering** — Phoenix LiveView components for inline SVG visualization
- **Caching** — ETS-based cache with automatic invalidation via PubSub
- **Zero dependencies** — Core analysis has no external dependencies
## Installation
Add to your `mix.exs`:
```elixir
def deps do
[
{:microprints, "~> 0.1"}
]
end
```
## Usage
### Generate a microprint
```elixir
Microprints.generate("path/to/file.ex")
# => {:ok, %{lines: [...], line_count: 42}}
```
### Get color for a syntax type
```elixir
Microprints.color_for(:function_def)
# => "#EF4444"
```
### Get the color legend
```elixir
Microprints.color_legend()
# => [{"Function", "#EF4444"}, {"Module", "#EC4899"}, ...]
```
### Render in Phoenix LiveView
```elixir
<%= live_component @live_view, MicroprintComponent,
microprint: microprint,
width: 200,
max_height: 100,
clickable: true,
file_path: file_path %>
```
### Cache with automatic invalidation
```elixir
# Start the cache (usually in your dev tools supervision tree)
MicroprintCache.start_link(pubsub: MyApp.PubSub)
# Get or generate
{:ok, microprint} = MicroprintCache.get_microprint("path/to/file.ex")
```
## Color Legend
| Syntax Element | Color |
|----------------|-------|
| Function def | 🔴 Red |
| Module | 🩷 Pink |
| Keyword | 🟣 Purple |
| String | 🟢 Green |
| Comment | ⚫ Gray |
| Atom | 🟠 Amber |
| Number | 🔵 Blue |
| Other | ⚪ Light gray |
## Development
```bash
cd microprints-git
mix test
```
## License
MIT

1
deps Symbolic link
View File

@ -0,0 +1 @@
../deps

115944
erl_crash.dump Normal file

File diff suppressed because one or more lines are too long

428
lib/microprint.ex Normal file
View File

@ -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

103
lib/microprint_cache.ex Normal file
View File

@ -0,0 +1,103 @@
defmodule Microprints.MicroprintCache do
@moduledoc """
GenServer that caches microprint visualizations in ETS.
Subscribes to a PubSub topic and invalidates cache entries when files
change via LiveReload (or any other file watcher).
## Configuration
The PubSub module and topic can be configured via `Application` environment:
config :microprints,
pubsub: MyApp.PubSub,
pubsub_topic: "dev_tools_files"
Defaults to `Phoenix.PubSub` and `"dev_tools_files"`.
"""
use GenServer
alias Microprints.Microprint
@table_name :microprint_cache
@live_reload_topic "dev_tools_files"
# Client API
@doc """
Starts the MicroprintCache GenServer.
## Options
* `:pubsub` - The PubSub module to use (defaults to `Phoenix.PubSub`)
* `:pubsub_topic` - The PubSub topic to subscribe to (defaults to `"dev_tools_files"`)
"""
def start_link(opts \\ []) do
pubsub = opts[:pubsub] || Application.get_env(:microprints, :pubsub, Phoenix.PubSub)
pubsub_topic = opts[:pubsub_topic] || Application.get_env(:microprints, :pubsub_topic, @live_reload_topic)
GenServer.start_link(__MODULE__, %{pubsub: pubsub, pubsub_topic: pubsub_topic}, name: __MODULE__)
end
@doc """
Returns the microprint for the given file path.
Returns cached version if available, otherwise generates and caches it.
"""
@spec get_microprint(String.t()) :: {:ok, Microprint.microprint()} | {:error, term()}
def get_microprint(path) do
absolute_path = Path.expand(path)
case :ets.lookup(@table_name, absolute_path) do
[{^absolute_path, microprint}] ->
{:ok, microprint}
[] ->
case Microprint.generate(absolute_path) do
{:ok, microprint} ->
:ets.insert(@table_name, {absolute_path, microprint})
{:ok, microprint}
error ->
error
end
end
end
@doc """
Invalidates the cache entry for the given path.
"""
@spec invalidate(String.t()) :: true
def invalidate(path) do
absolute_path = Path.expand(path)
:ets.delete(@table_name, absolute_path)
end
@doc """
Clears all cached microprints.
"""
@spec clear_all() :: true
def clear_all do
:ets.delete_all_objects(@table_name)
end
# Server callbacks
@impl true
def init(%{pubsub: pubsub, pubsub_topic: pubsub_topic}) do
table = :ets.new(@table_name, [:named_table, :public, :set, read_concurrency: true])
Phoenix.PubSub.subscribe(pubsub, pubsub_topic)
{:ok, %{table: table, pubsub_topic: pubsub_topic}}
end
@impl true
def handle_info({:phoenix_live_reload, _topic, path}, state) do
invalidate(path)
{:noreply, state}
end
def handle_info(_msg, state) do
{:noreply, state}
end
end

234
lib/microprint_component.ex Normal file
View File

@ -0,0 +1,234 @@
defmodule Microprints.MicroprintComponent do
@moduledoc """
Phoenix Component for rendering microprint SVG visualizations.
Renders each line of code as a colored horizontal stripe,
creating a compact visual fingerprint of the file's structure.
"""
use Phoenix.Component
alias Microprints.Microprint
# Pixels per indent level (1 space = 1 indent unit)
@indent_scale 2
# Maximum indent in pixels (cap at 40% of width)
@max_indent_ratio 0.4
@doc """
Renders a microprint visualization as an inline SVG.
## Attributes
* `:microprint` - The microprint map with `:lines` (list of line info maps)
* `:width` - SVG width in pixels (default: 200)
* `:max_height` - Maximum SVG height in pixels (default: 100)
* `:clickable` - Whether lines are clickable to highlight (default: false)
* `:file_path` - File path for click events (required if clickable)
* `:highlighted_line` - Currently highlighted line number (0-indexed)
"""
attr :microprint, :map, required: true
attr :width, :integer, default: 200
attr :max_height, :integer, default: 100
attr :clickable, :boolean, default: false
attr :file_path, :string, default: nil
attr :highlighted_line, :integer, default: nil
def microprint(assigns) do
lines = assigns.microprint.lines
line_count = length(lines)
# Each line is 1px, but cap at max_height
height = min(line_count, assigns.max_height)
# If we have more lines than max_height, we need to sample
{displayed_lines, line_mapping} =
if line_count > assigns.max_height do
{sample_lines(lines, assigns.max_height),
sample_line_mapping(line_count, assigns.max_height)}
else
{lines, Enum.to_list(0..(line_count - 1))}
end
max_indent = trunc(assigns.width * @max_indent_ratio)
# Find max line length for proportional scaling
max_length = displayed_lines |> Enum.map(& &1.length) |> Enum.max(fn -> 1 end) |> max(1)
# Calculate x offset and width for each line
rendered_lines =
displayed_lines
|> Enum.with_index()
|> Enum.map(fn {line_info, index} ->
base_x = min(line_info.indent * @indent_scale, max_indent)
available_width = assigns.width - base_x
# Map display index to actual line number
actual_line = Enum.at(line_mapping, index, index)
highlighted = assigns.highlighted_line == actual_line
# Build segments for this line
segments =
if line_info[:segments] && line_info.segments != [] do
# Multi-color: render each segment
scale = if line_info.length > 0, do: available_width / line_info.length, else: 1
line_info.segments
|> Enum.map(fn seg ->
seg_x = base_x + trunc(seg.start * scale)
seg_width = max(1, trunc(seg.length * scale))
%{
x: seg_x,
width: seg_width,
color: seg.color,
y: index,
line_number: actual_line,
highlighted: highlighted
}
end)
else
# Single color fallback
width =
if line_info.length == 0 do
2
else
max(2, trunc(line_info.length / max_length * available_width))
end
[
%{
x: base_x,
width: width,
color: line_info.color,
y: index,
line_number: actual_line,
highlighted: highlighted
}
]
end
%{y: index, line_number: actual_line, highlighted: highlighted, segments: segments}
end)
assigns =
assigns
|> assign(:height, height)
|> assign(:rendered_lines, rendered_lines)
~H"""
<svg
width={@width}
height={@height}
viewBox={"0 0 #{@width} #{@height}"}
class={"block mt-1 #{if @clickable, do: "cursor-pointer", else: ""}"}
>
<%= for line <- @rendered_lines do %>
<%= for segment <- line.segments do %>
<rect
x={segment.x}
y={segment.y}
width={segment.width}
height="1"
fill={segment.color}
opacity={if segment.highlighted, do: "1", else: "0.8"}
phx-click={if @clickable, do: "highlight_line"}
phx-value-line={if @clickable, do: segment.line_number}
phx-value-path={if @clickable, do: @file_path}
/>
<% end %>
<% end %>
<%= if @highlighted_line do %>
<rect
x="0"
y={highlight_y(@rendered_lines, @highlighted_line)}
width={@width}
height="3"
fill="none"
stroke="#000"
stroke-width="1"
pointer-events="none"
/>
<% end %>
</svg>
"""
end
defp highlight_y(rendered_lines, highlighted_line) do
case Enum.find(rendered_lines, &(&1.line_number == highlighted_line)) do
nil -> 0
line -> max(0, line.y - 1)
end
end
defp sample_line_mapping(line_count, max_count) do
step = line_count / max_count
0..(max_count - 1)
|> Enum.map(fn i -> trunc(i * step) end)
end
# Sample lines evenly when we have more lines than pixels available
defp sample_lines(lines, max_count) do
line_count = length(lines)
step = line_count / max_count
0..(max_count - 1)
|> Enum.map(fn i ->
index = trunc(i * step)
Enum.at(lines, index)
end)
end
@doc """
Renders a color legend for microprint visualizations.
Shows each syntax type with its corresponding color.
"""
def microprint_legend(assigns) do
assigns = assign(assigns, :legend, Microprint.color_legend())
~H"""
<div class="flex flex-wrap gap-3 text-xs">
<%= for {label, color} <- @legend do %>
<div class="flex items-center gap-1">
<span
class="w-3 h-3 inline-block rounded-sm"
style={"background: #{color};"}
>
</span>
<span class="text-base-content/60">{label}</span>
</div>
<% end %>
</div>
"""
end
@doc """
Renders source code with line numbers and optional highlighting.
## Attributes
* `:content` - The source code content as a string
* `:highlighted_line` - Line number to highlight (0-indexed, optional)
* `:language` - Language for syntax highlighting (e.g., "elixir", "javascript", "typescript", "markdown")
"""
attr :content, :string, required: true
attr :highlighted_line, :integer, default: nil
attr :language, :string, default: nil
def source_viewer(assigns) do
~H"""
<div
id="source-viewer"
class="source-viewer mt-2 max-h-96 overflow-auto rounded font-mono text-xs"
phx-hook="SourceViewer"
data-highlighted-line={@highlighted_line}
data-language={@language}
style="background: var(--sv-bg); color: var(--sv-text);"
>
<pre id="source-viewer-pre" phx-update="ignore" class="m-0 p-2"><code class={"language-#{@language || "plaintext"}"}><%= @content %></code></pre>
</div>
"""
end
end

70
lib/microprints.ex Normal file
View File

@ -0,0 +1,70 @@
defmodule Microprints do
@moduledoc """
Microprints compact visual representations of source code files.
A microprint is a compact visual fingerprint where each line of code becomes
a colored stripe based on its syntax elements (function definitions, strings,
comments, etc.).
## Installation
Add to your `mix.exs`:
```elixir
def deps do
[
{:microprints, "~> 0.1", only: :dev}
]
end
```
## Usage
### Generate a microprint
Microprints.generate("path/to/file.ex")
# => {:ok, %{lines: [...], line_count: 42}}
### Get color for a syntax type
Microprints.color_for(:function_def)
# => "#EF4444"
### Get the color legend
Microprints.color_legend()
# => [{"Function", "#EF4444"}, {"Module", "#EC4899"}, ...]
## Components
The `MicroprintComponent` module provides Phoenix LiveView components for
rendering microprint visualizations as SVG.
The `MicroprintCache` module provides ETS-based caching with LiveReload
support for automatic cache invalidation.
## Examples
See the test suite for more usage examples.
"""
alias Microprints.Microprint
@doc """
Delegates to `Microprint.generate/1`.
"""
@spec generate(String.t()) :: {:ok, Microprint.microprint()} | {:error, term()}
def generate(path), do: Microprint.generate(path)
@doc """
Delegates to `Microprint.color_for/1`.
"""
@spec color_for(atom()) :: String.t()
def color_for(type), do: Microprint.color_for(type)
@doc """
Delegates to `Microprint.color_legend/0`.
"""
@spec color_legend() :: [{String.t(), String.t()}]
def color_legend(), do: Microprint.color_legend()
end

60
mix.exs Normal file
View File

@ -0,0 +1,60 @@
defmodule Microprints.MixProject do
use Mix.Project
def project do
[
app: :microprints,
version: "0.1.0",
elixir: "~> 1.15",
description: "Generate microprint visualizations from source code files",
start_permanent: Mix.env() == :prod,
elixirc_paths: elixirc_paths(Mix.env()),
deps: deps(),
package: package(),
aliases: aliases()
]
end
# Configuration for the OTP application.
def application do
[extra_applications: [:logger]]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
defp deps do
[
{:phoenix, path: "../deps/phoenix", override: true},
{:phoenix_live_view, path: "../deps/phoenix_live_view", override: true},
{:phoenix_pubsub, path: "../deps/phoenix_pubsub", override: true},
{:phoenix_template, path: "../deps/phoenix_template", override: true},
{:plug, path: "../deps/plug", override: true},
{:plug_crypto, path: "../deps/plug_crypto", override: true},
{:telemetry, path: "../deps/telemetry", override: true},
{:websock, path: "../deps/websock", override: true},
{:websock_adapter, path: "../deps/websock_adapter", override: true},
{:mime, path: "../deps/mime", override: true},
{:phoenix_html, path: "../deps/phoenix_html", override: true}
]
end
defp package do
[
licenses: ["MIT"],
files: ["lib", "mix.exs", "README.md", "LICENSE"],
maintainers: ["Willem"],
links: %{"GitHub" => "https://github.com/..."}
]
end
# Aliases are shortcuts or tasks specific to the current project.
defp aliases do
[
test: ["test"],
format: "format"
]
end
end

View File

@ -0,0 +1,106 @@
defmodule Microprints.MicroprintCacheTest do
use ExUnit.Case, async: false
alias Microprints.MicroprintCache
@fixture_path Path.expand("../support/fixtures/sample.ex", __DIR__)
setup do
# Start a fresh test PubSub server with a unique name
pubsub_name = Module.concat(Microprints.TestPubSub, inspect(System.unique_integer([:positive])))
child_spec = Phoenix.PubSub.child_spec(name: pubsub_name, adapter: Phoenix.PubSub.PG2)
{:ok, sup_pid} = Supervisor.start_link([child_spec], strategy: :one_for_one)
Process.put(:test_pubsub_supervisor, sup_pid)
# Stop any existing cache first
if pid = GenServer.whereis(MicroprintCache) do
GenServer.stop(pid, :normal, 5000)
end
# Start fresh cache for this test
{:ok, _pid} = MicroprintCache.start_link(pubsub: pubsub_name)
on_exit(fn ->
if sup_pid = Process.get(:test_pubsub_supervisor) do
Supervisor.stop(sup_pid)
Process.delete(:test_pubsub_supervisor)
end
end)
%{pubsub_name: pubsub_name}
end
describe "get_microprint/1" do
test "returns microprint for valid file" do
assert {:ok, microprint} = MicroprintCache.get_microprint(@fixture_path)
assert is_map(microprint)
assert is_list(microprint.lines)
end
test "caches microprint on first access" do
# First call generates and caches
{:ok, mp1} = MicroprintCache.get_microprint(@fixture_path)
# Second call should return cached version (same reference)
{:ok, mp2} = MicroprintCache.get_microprint(@fixture_path)
assert mp1 == mp2
end
test "returns error for missing file" do
assert {:error, :enoent} = MicroprintCache.get_microprint("/nonexistent/file.ex")
end
end
describe "invalidate/1" do
test "removes cached entry for path" do
# Cache a microprint
{:ok, _} = MicroprintCache.get_microprint(@fixture_path)
# Invalidate it
MicroprintCache.invalidate(@fixture_path)
# The ETS table should not have this entry anymore
# (We verify by checking it's regenerated on next access)
{:ok, mp} = MicroprintCache.get_microprint(@fixture_path)
assert is_map(mp)
end
end
describe "clear_all/0" do
test "removes all cached entries" do
# Cache a microprint
{:ok, _} = MicroprintCache.get_microprint(@fixture_path)
# Clear all
MicroprintCache.clear_all()
# Should still work (regenerates)
{:ok, mp} = MicroprintCache.get_microprint(@fixture_path)
assert is_map(mp)
end
end
describe "PubSub integration" do
test "invalidates cache on live_reload message", %{pubsub_name: pubsub_name} do
# Cache a microprint
{:ok, _} = MicroprintCache.get_microprint(@fixture_path)
# Simulate LiveReload notification
Phoenix.PubSub.broadcast(
pubsub_name,
"dev_tools_files",
{:phoenix_live_reload, "dev_tools_files", @fixture_path}
)
# Give the GenServer time to process
Process.sleep(10)
# The cache entry should have been invalidated
# (We can't directly check ETS, but the behavior is verified by the flow)
{:ok, mp} = MicroprintCache.get_microprint(@fixture_path)
assert is_map(mp)
end
end
end

View File

@ -0,0 +1,158 @@
defmodule Microprints.MicroprintTest do
use ExUnit.Case, async: true
alias Microprints.Microprint
@fixture_path Path.expand("../support/fixtures/sample.ex", __DIR__)
describe "generate/1" do
test "returns microprint structure for valid file" do
assert {:ok, microprint} = Microprint.generate(@fixture_path)
assert is_map(microprint)
assert is_list(microprint.lines)
assert is_integer(microprint.line_count)
assert microprint.line_count == length(microprint.lines)
end
test "returns error for missing file" do
assert {:error, :enoent} = Microprint.generate("/nonexistent/file.ex")
end
test "all lines have color, indent, and length" do
{:ok, microprint} = Microprint.generate(@fixture_path)
Enum.each(microprint.lines, fn line_info ->
assert is_map(line_info)
assert String.starts_with?(line_info.color, "#"), "Expected hex color"
assert String.length(line_info.color) == 7, "Expected 7-char hex color"
assert is_integer(line_info.indent)
assert line_info.indent >= 0
assert is_integer(line_info.length)
assert line_info.length >= 0
end)
end
test "captures indentation levels" do
{:ok, microprint} = Microprint.generate(@fixture_path)
# First line (defmodule) should have 0 indent
first_line = List.first(microprint.lines)
assert first_line.indent == 0
# Find a line with indent (function body lines should be indented)
indented_lines = Enum.filter(microprint.lines, &(&1.indent > 0))
assert length(indented_lines) > 0, "Expected some indented lines"
end
test "captures line lengths" do
{:ok, microprint} = Microprint.generate(@fixture_path)
# First line "defmodule Sample do" has content
first_line = List.first(microprint.lines)
assert first_line.length > 0
# Empty lines should have length 0
empty_lines = Enum.filter(microprint.lines, &(&1.length == 0))
assert length(empty_lines) > 0, "Expected some empty lines"
# Lines vary in length
lengths = Enum.map(microprint.lines, & &1.length) |> Enum.uniq()
assert length(lengths) > 1, "Expected varying line lengths"
end
end
describe "color classification for Elixir files" do
test "module definitions get module color" do
{:ok, microprint} = Microprint.generate(@fixture_path)
# First line is "defmodule Sample do"
first_line = List.first(microprint.lines)
assert first_line.color == Microprint.color_for(:module)
end
test "function definitions get function_def color" do
{:ok, microprint} = Microprint.generate(@fixture_path)
colors = Enum.map(microprint.lines, & &1.color)
assert Microprint.color_for(:function_def) in colors
end
test "comments get comment color" do
{:ok, microprint} = Microprint.generate(@fixture_path)
colors = Enum.map(microprint.lines, & &1.color)
assert Microprint.color_for(:comment) in colors
end
test "atom-heavy lines get atom color" do
{:ok, microprint} = Microprint.generate(@fixture_path)
colors = Enum.map(microprint.lines, & &1.color)
assert Microprint.color_for(:atom) in colors
end
end
describe "color_for/1" do
test "returns expected colors for known types" do
assert Microprint.color_for(:keyword) == "#8B5CF6"
assert Microprint.color_for(:string) == "#10B981"
assert Microprint.color_for(:comment) == "#6B7280"
assert Microprint.color_for(:function_def) == "#EF4444"
assert Microprint.color_for(:atom) == "#F59E0B"
assert Microprint.color_for(:number) == "#3B82F6"
assert Microprint.color_for(:module) == "#EC4899"
end
test "returns default color for unknown types" do
assert Microprint.color_for(:unknown) == "#D1D5DB"
end
end
describe "color_legend/0" do
test "returns list of label-color tuples" do
legend = Microprint.color_legend()
assert is_list(legend)
assert length(legend) == 8
for {label, color} <- legend do
assert is_binary(label)
assert String.starts_with?(color, "#")
end
end
test "includes key syntax types" do
legend = Microprint.color_legend()
labels = Enum.map(legend, fn {label, _} -> label end)
assert "Function" in labels
assert "Module" in labels
assert "Keyword" in labels
assert "String" in labels
assert "Comment" in labels
end
end
describe "non-Elixir files" do
test "handles JavaScript-like files with generic colorization" do
# Create a temp JS file
tmp_path = Path.join(System.tmp_dir!(), "test_#{:rand.uniform(100_000)}.js")
File.write!(tmp_path, """
// A comment
function hello() {
return "world";
}
""")
{:ok, microprint} = Microprint.generate(tmp_path)
assert microprint.line_count == 5
# First line is a comment
first_line = List.first(microprint.lines)
assert first_line.color == Microprint.color_for(:comment)
# Third line has indent (inside function)
third_line = Enum.at(microprint.lines, 2)
assert third_line.indent > 0
File.rm!(tmp_path)
end
end
end

View File

@ -0,0 +1,27 @@
defmodule Sample do
@moduledoc """
A sample module for testing microprint generation.
"""
@my_attribute :value
@doc "Returns a greeting message"
def greet(name) do
"Hello, #{name}!"
end
# Private helper
defp format_name(name) do
String.capitalize(name)
end
@type status :: :ok | :error
def calculate(x, y) when is_number(x) and is_number(y) do
x + y
end
def list_items do
:standalone_atom
end
end

View File

@ -0,0 +1,38 @@
defmodule Microprints.TestPubSub do
@moduledoc """
Test helper for starting a Phoenix.PubSub server.
Provides `start_link/1` to start a PubSub server and returns the
server name for use with MicroprintCache.
"""
@doc """
Starts a test PubSub server and returns its name.
"""
def start_link(opts \\ []) do
name = opts[:name] || Microprints.TestPubSub
child_spec = Phoenix.PubSub.child_spec(name: name, adapter: Phoenix.PubSub.PG2)
# Start a temporary supervisor to host the PubSub server
case Supervisor.start_link([child_spec], strategy: :one_for_one) do
{:ok, sup_pid} ->
Process.put(:test_pubsub_supervisor, sup_pid)
{:error, {:already_started, sup_pid}} ->
Process.put(:test_pubsub_supervisor, sup_pid)
end
name
end
@doc """
Stops the test PubSub server.
"""
def stop do
if sup_pid = Process.get(:test_pubsub_supervisor) do
Supervisor.stop(sup_pid)
Process.delete(:test_pubsub_supervisor)
end
end
end

1
test/test_helper.exs Normal file
View File

@ -0,0 +1 @@
ExUnit.start()