Extracted from qwan-tracker
This commit is contained in:
commit
e06832fb13
6
.formatter.exs
Normal file
6
.formatter.exs
Normal 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
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Dependencies
|
||||||
|
deps/
|
||||||
|
_build/
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
*.ez
|
||||||
|
beam/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Mix
|
||||||
|
*.rbe
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
101
README.md
Normal 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
|
||||||
115944
erl_crash.dump
Normal file
115944
erl_crash.dump
Normal file
File diff suppressed because one or more lines are too long
428
lib/microprint.ex
Normal file
428
lib/microprint.ex
Normal 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
103
lib/microprint_cache.ex
Normal 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
234
lib/microprint_component.ex
Normal 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
70
lib/microprints.ex
Normal 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
60
mix.exs
Normal 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
|
||||||
106
test/microprints/microprint_cache_test.exs
Normal file
106
test/microprints/microprint_cache_test.exs
Normal 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
|
||||||
158
test/microprints/microprint_test.exs
Normal file
158
test/microprints/microprint_test.exs
Normal 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
|
||||||
27
test/support/fixtures/sample.ex
Normal file
27
test/support/fixtures/sample.ex
Normal 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
|
||||||
38
test/support/test_pubsub.ex
Normal file
38
test/support/test_pubsub.ex
Normal 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
1
test/test_helper.exs
Normal file
@ -0,0 +1 @@
|
|||||||
|
ExUnit.start()
|
||||||
Loading…
x
Reference in New Issue
Block a user