//// gliff — A text diffing library for Gleam.
////
//// Provides Myers and Patience diff algorithms, unified diff formatting,
//// patch application, semantic cleanup, and inline highlighting.
import gleam/list
import gleam/string
import gliff/internal/ansi
import gliff/internal/budget
import gliff/internal/cleanup
import gliff/internal/fuzzy_patch
import gliff/internal/inline
import gliff/internal/merge
import gliff/internal/myers
import gliff/internal/patch
import gliff/internal/patience
import gliff/internal/similarity
import gliff/internal/tokenize
import gliff/internal/unified
import gliff/types.{
type DiffConfig, type DiffResult, type Edit, type Hunk, type InlineEdit,
type MergeResult, type RawEdit, Complete, Delete, DiffConfig, Equal, Insert,
Myers, NoCleanup, Patience, RawDelete, RawEqual, RawInsert, SemanticCleanup,
SemanticLosslessCleanup, Truncated,
}
/// Compute a line-level diff between two strings using the Myers algorithm.
///
/// Returns a list of edit operations (Equal, Insert, Delete) where each
/// operation contains one or more contiguous lines.
pub fn diff(old: String, new: String) -> List(Edit) {
let old_lines = split_lines(old)
let new_lines = split_lines(new)
let raw_edits = myers.diff(old_lines, new_lines)
group_edits(raw_edits)
}
/// Compute a character-level diff between two strings using the Myers algorithm.
///
/// Each element in the returned edit list represents one or more contiguous
/// grapheme clusters that share the same edit type.
pub fn diff_chars(old: String, new: String) -> List(Edit) {
let old_chars = string.to_graphemes(old)
let new_chars = string.to_graphemes(new)
let raw_edits = myers.diff(old_chars, new_chars)
group_edits(raw_edits)
}
/// Format a list of edits as a unified diff string.
///
/// The output matches the format produced by `diff -u`, with 3 lines of
/// context around each change. `old_name` and `new_name` appear in the
/// `---` and `+++` header lines.
pub fn to_unified(
edits: List(Edit),
old_name old_name: String,
new_name new_name: String,
) -> String {
unified.to_unified(edits, old_name:, new_name:)
}
/// Format edits as a unified diff with a custom number of context lines.
///
/// Same as `to_unified` but allows specifying how many unchanged lines
/// surround each change. Equivalent to `diff -U<n>`.
pub fn to_unified_with(
edits: List(Edit),
old_name old_name: String,
new_name new_name: String,
context context: Int,
) -> String {
unified.to_unified_with(edits, old_name:, new_name:, context:)
}
/// Render edits as a colored diff string using ANSI escape codes.
///
/// Deletions appear in red, insertions in green, and hunk headers in cyan.
/// Output follows the same structure as unified diff format.
pub fn to_ansi(
edits: List(Edit),
old_name old_name: String,
new_name new_name: String,
) -> String {
ansi.to_ansi(edits, old_name:, new_name:)
}
/// Render edits with ANSI colors and inline character highlighting.
///
/// Changed characters within modified lines are rendered in bold,
/// making it easy to see exactly what changed at a glance.
pub fn to_ansi_inline(edits: List(Edit)) -> String {
ansi.to_ansi_inline(edits)
}
/// Parse a unified diff string into a list of hunks.
///
/// Accepts the standard format produced by `diff -u` or `to_unified`.
/// Returns an error if the input is malformed.
pub fn from_unified(input: String) -> Result(List(Hunk), String) {
unified.from_unified(input)
}
/// Apply edit operations to a text string to produce the target text.
///
/// The fundamental property is:
/// `apply_patch(old, diff(old, new)) == Ok(new)`
///
/// Returns an error if the text doesn't match the expected content
/// described by the Equal and Delete operations.
pub fn apply_patch(text: String, edits: List(Edit)) -> Result(String, String) {
patch.apply_patch(text, edits)
}
/// Apply edit operations with fuzzy matching for context/delete lines.
///
/// When Equal or Delete lines don't match exactly, accepts lines that
/// are similar enough based on character-level comparison.
/// Tolerance ranges from 0.0 (exact match, same as `apply_patch`) to
/// 1.0 (accept any line). A value of 0.6 works well for most cases.
pub fn apply_patch_fuzzy(
text: String,
edits: List(Edit),
tolerance tolerance: Float,
) -> Result(String, String) {
fuzzy_patch.apply_patch_fuzzy(text, edits, tolerance:)
}
/// Compute the similarity ratio between two texts from their diff result.
///
/// Returns a value between 0.0 (completely different) and 1.0 (identical).
/// Calculated as `2 * matching_elements / total_elements`.
pub fn similarity(edits: List(Edit)) -> Float {
similarity.ratio(edits)
}
/// Compute a line-level diff using the Myers algorithm (explicit alias for `diff`).
pub fn diff_myers(old: String, new: String) -> List(Edit) {
diff(old, new)
}
/// Compute a line-level diff using the Patience algorithm.
///
/// Patience diff anchors on lines that appear exactly once in both texts,
/// then recursively diffs the gaps. This often produces more readable
/// output for code changes where blocks are reordered.
pub fn diff_patience(old: String, new: String) -> List(Edit) {
let old_lines = split_lines(old)
let new_lines = split_lines(new)
let raw_edits = patience.diff(old_lines, new_lines)
group_edits(raw_edits)
}
/// Compute a word-level diff between two strings.
///
/// Tokenizes input by whitespace boundaries (preserving whitespace as
/// separate tokens), then diffs the token sequences. Each edit contains
/// one or more word/whitespace tokens.
pub fn diff_words(old: String, new: String) -> List(Edit) {
let old_tokens = tokenize.words(old)
let new_tokens = tokenize.words(new)
let raw_edits = myers.diff(old_tokens, new_tokens)
group_edits(raw_edits)
}
/// Eliminate trivial equalities and merge adjacent edits.
///
/// Removes small Equal sections that are surrounded by larger changes,
/// absorbing them into the Delete and Insert operations. This produces
/// fewer, larger edit blocks that are easier to read.
pub fn cleanup_semantic(edits: List(Edit)) -> List(Edit) {
cleanup.semantic(edits)
}
/// Shift edit boundaries to natural positions without changing semantics.
///
/// Moves edit boundaries to align with word boundaries, sentence endings,
/// or blank lines based on a scoring system. The resulting diff applies
/// identically but reads more naturally.
pub fn cleanup_semantic_lossless(edits: List(Edit)) -> List(Edit) {
cleanup.semantic_lossless(edits)
}
/// Enrich line-level edits with character-level change highlighting.
///
/// For adjacent Delete/Insert pairs, computes a character-level sub-diff
/// and returns spans marking which characters actually changed. Equal
/// edits pass through as InlineEqual.
pub fn inline_highlight(edits: List(Edit)) -> List(InlineEdit) {
inline.highlight(edits)
}
/// Perform a 3-way merge between two diverged versions of a base text.
///
/// Computes diff(base, ours) and diff(base, theirs), then combines
/// the changes. When both sides modify the same region differently,
/// a conflict is reported with Git-style conflict markers.
///
/// Returns `MergeOk` if the merge is clean, or `MergeConflict` with
/// the merged text (including `<<<<<<<`/`=======`/`>>>>>>>` markers)
/// and a list of conflicts.
pub fn merge3(base: String, ours: String, theirs: String) -> MergeResult {
merge.merge3(base, ours, theirs)
}
/// Create a default diff configuration.
///
/// Returns `DiffConfig(algorithm: Myers, cleanup: NoCleanup, max_iterations: 0)`
/// where `max_iterations: 0` means unlimited computation.
pub fn default_config() -> DiffConfig {
DiffConfig(algorithm: Myers, cleanup: NoCleanup, max_iterations: 0)
}
/// Compute a line-level diff with full configuration.
///
/// Allows selecting the algorithm, cleanup mode, and iteration budget.
/// Returns `Complete` if the diff finished normally, or `Truncated` if
/// the iteration budget was exceeded (in which case a crude but correct
/// fallback diff is returned).
pub fn diff_with(old: String, new: String, config: DiffConfig) -> DiffResult {
let old_tokens = split_lines(old)
let new_tokens = split_lines(new)
let b = budget.from_max(config.max_iterations)
let #(raw_edits, complete) = case config.algorithm {
Myers -> myers.diff_with_budget(old_tokens, new_tokens, b)
Patience -> #(patience.diff(old_tokens, new_tokens), True)
}
let edits = group_edits(raw_edits)
let edits_cleaned = apply_cleanup(edits, config.cleanup)
case complete {
True -> Complete(edits: edits_cleaned)
False -> Truncated(edits: edits_cleaned)
}
}
/// Compute a character-level diff with full configuration.
///
/// Same as `diff_with` but operates on grapheme clusters instead of lines.
pub fn diff_chars_with(
old: String,
new: String,
config: DiffConfig,
) -> DiffResult {
let old_chars = string.to_graphemes(old)
let new_chars = string.to_graphemes(new)
let b = budget.from_max(config.max_iterations)
let #(raw_edits, complete) = case config.algorithm {
Myers -> myers.diff_with_budget(old_chars, new_chars, b)
Patience -> #(patience.diff(old_chars, new_chars), True)
}
let edits = group_edits(raw_edits)
let edits_cleaned = apply_cleanup(edits, config.cleanup)
case complete {
True -> Complete(edits: edits_cleaned)
False -> Truncated(edits: edits_cleaned)
}
}
fn apply_cleanup(edits: List(Edit), mode: types.Cleanup) -> List(Edit) {
case mode {
NoCleanup -> edits
SemanticCleanup -> cleanup.semantic(edits)
SemanticLosslessCleanup -> cleanup.semantic_lossless(edits)
}
}
fn split_lines(text: String) -> List(String) {
case text {
"" -> []
_ -> string.split(text, "\n")
}
}
fn group_edits(raw: List(RawEdit)) -> List(Edit) {
group_edits_loop(raw, [])
|> list.reverse
}
fn group_edits_loop(raw: List(RawEdit), acc: List(Edit)) -> List(Edit) {
case raw {
[] -> acc
[RawEqual(v), ..rest] -> {
let #(equals, remaining) = collect_equals(rest, [v])
group_edits_loop(remaining, [Equal(list.reverse(equals)), ..acc])
}
[RawInsert(v), ..rest] -> {
let #(inserts, remaining) = collect_inserts(rest, [v])
group_edits_loop(remaining, [Insert(list.reverse(inserts)), ..acc])
}
[RawDelete(v), ..rest] -> {
let #(deletes, remaining) = collect_deletes(rest, [v])
group_edits_loop(remaining, [Delete(list.reverse(deletes)), ..acc])
}
}
}
fn collect_equals(
raw: List(RawEdit),
acc: List(String),
) -> #(List(String), List(RawEdit)) {
case raw {
[RawEqual(v), ..rest] -> collect_equals(rest, [v, ..acc])
_ -> #(acc, raw)
}
}
fn collect_inserts(
raw: List(RawEdit),
acc: List(String),
) -> #(List(String), List(RawEdit)) {
case raw {
[RawInsert(v), ..rest] -> collect_inserts(rest, [v, ..acc])
_ -> #(acc, raw)
}
}
fn collect_deletes(
raw: List(RawEdit),
acc: List(String),
) -> #(List(String), List(RawEdit)) {
case raw {
[RawDelete(v), ..rest] -> collect_deletes(rest, [v, ..acc])
_ -> #(acc, raw)
}
}