Current section
Files
Jump to
Current section
Files
lib/opal/tool/hashline.ex
defmodule Opal.Tool.Hashline do
@moduledoc """
Hashline utilities for content-addressed line references.
Every line is tagged with a short content hash so the LLM can reference
specific lines without reproducing their full content. The format is:
N:hash|content
Where `N` is the 1-indexed line number and `hash` is a 2-character hex
digest of the trimmed line content. The hash acts as a checksum — if the
file changes, stale hashes won't match and the edit is rejected.
## Hash computation
Uses `:erlang.phash2/2` of the trimmed line content, modulo 256, formatted
as zero-padded lowercase hex. Empty lines always hash to the same value
but are distinguished by line number.
"""
@doc """
Computes a 2-character hex hash for a single line's content.
iex> Opal.Tool.Hashline.line_hash(" return 42;")
"8a" # example — actual value depends on phash2
"""
@spec line_hash(String.t()) :: String.t()
def line_hash(line) do
line
|> String.trim()
|> :erlang.phash2(256)
|> Integer.to_string(16)
|> String.downcase()
|> String.pad_leading(2, "0")
end
@doc """
Tags lines with `N:hash|` prefixes.
Accepts raw file content and an optional starting line number (default 1).
Returns the tagged string.
"""
@spec tag_lines(String.t(), pos_integer()) :: String.t()
def tag_lines(content, start_line \\ 1) do
content
|> String.replace("\r\n", "\n")
|> String.split("\n")
|> Enum.with_index(start_line)
|> Enum.map_join("\n", fn {line, num} ->
hash = line_hash(line)
"#{num}:#{hash}|#{line}"
end)
end
@doc """
Parses a `"N:hash"` anchor string into `{line_number, hash}`.
Returns `{:ok, {line, hash}}` or `{:error, reason}`.
"""
@spec parse_anchor(String.t()) :: {:ok, {pos_integer(), String.t()}} | {:error, String.t()}
def parse_anchor(anchor) when is_binary(anchor) do
case String.split(anchor, ":", parts: 2) do
[line_str, hash] when byte_size(hash) > 0 ->
case Integer.parse(line_str) do
{line, ""} when line > 0 -> {:ok, {line, String.downcase(hash)}}
_ -> {:error, "Invalid line number in anchor: #{anchor}"}
end
_ ->
{:error, "Invalid anchor format '#{anchor}' — expected 'N:hash' (e.g. '5:a3')"}
end
end
@doc """
Validates that line `line_num` in `lines` (0-indexed list) has the expected hash.
Returns `:ok` or `{:error, reason}`.
"""
@spec validate_hash([String.t()], pos_integer(), String.t()) :: :ok | {:error, String.t()}
def validate_hash(lines, line_num, expected_hash) do
idx = line_num - 1
cond do
idx < 0 or idx >= length(lines) ->
{:error, "Line #{line_num} is out of range (file has #{length(lines)} lines)"}
true ->
actual = line_hash(Enum.at(lines, idx))
if actual == expected_hash do
:ok
else
{:error,
"Hash mismatch on line #{line_num}: expected #{expected_hash}, got #{actual}. File may have changed since last read."}
end
end
end
end