harn-stdlib 0.8.24

Embedded Harn standard library source catalog
Documentation
// std/diff - line diff, unified diff, and colored diff renderers.
import { ansi_dim, ansi_error, ansi_info, ansi_success } from "std/ansi"
import { render_table } from "std/table"

type DiffOptions = {
  path?: string,
  from_label?: string,
  to_label?: string,
  context?: int,
  color?: bool,
  color_mode?: string,
}

fn __diff_lines_of(text) {
  return split(text ?? "", "\n")
}

fn __diff_op(kind, line, old_line, new_line) {
  return {kind: kind, line: line, old_line: old_line, new_line: new_line}
}

fn __diff_key(i, j) -> string {
  return to_string(i) + "," + to_string(j)
}

fn __diff_matrix(a, b) {
  let n = len(a)
  let m = len(b)
  var dp = {}
  var i = n - 1
  while i >= 0 {
    var j = m - 1
    while j >= 0 {
      if a[i] == b[j] {
        let next = dp[__diff_key(i + 1, j + 1)] ?? 0
        dp = dp + {[__diff_key(i, j)]: next + 1}
      } else {
        let down = dp[__diff_key(i + 1, j)] ?? 0
        let right = dp[__diff_key(i, j + 1)] ?? 0
        dp = dp + {[__diff_key(i, j)]: max(down, right)}
      }
      j -= 1
    }
    i -= 1
  }
  return dp
}

fn __diff_ops(a, b) {
  let dp = __diff_matrix(a, b)
  var ops = []
  var i = 0
  var j = 0
  while i < len(a) && j < len(b) {
    if a[i] == b[j] {
      ops = ops.push(__diff_op("equal", a[i], i + 1, j + 1))
      i += 1
      j += 1
    } else {
      let delete_score = dp[__diff_key(i + 1, j)] ?? 0
      let insert_score = dp[__diff_key(i, j + 1)] ?? 0
      if delete_score >= insert_score {
        ops = ops.push(__diff_op("delete", a[i], i + 1, j + 1))
        i += 1
      } else {
        ops = ops.push(__diff_op("insert", b[j], i + 1, j + 1))
        j += 1
      }
    }
  }
  while i < len(a) {
    ops = ops.push(__diff_op("delete", a[i], i + 1, j + 1))
    i += 1
  }
  while j < len(b) {
    ops = ops.push(__diff_op("insert", b[j], i + 1, j + 1))
    j += 1
  }
  return ops
}

fn __diff_stats(ops) {
  var insertions = 0
  var deletions = 0
  for op in ops {
    if op.kind == "insert" {
      insertions += 1
    } else if op.kind == "delete" {
      deletions += 1
    }
  }
  return {insertions: insertions, deletions: deletions, changed: insertions > 0 || deletions > 0}
}

fn __diff_compact_ops(ops, context) {
  if context == nil || context < 0 {
    return ops
  }
  var keep = {}
  var idx = 0
  while idx < len(ops) {
    if ops[idx].kind != "equal" {
      let start = max(0, idx - context)
      let end = min(len(ops), idx + context + 1)
      var cursor = start
      while cursor < end {
        keep = keep + {[to_string(cursor)]: true}
        cursor += 1
      }
    }
    idx += 1
  }
  var out = []
  var skipped = false
  idx = 0
  while idx < len(ops) {
    if keep[to_string(idx)] {
      if skipped {
        out = out.push({kind: "skip"})
        skipped = false
      }
      out = out.push(ops[idx])
    } else {
      skipped = true
    }
    idx += 1
  }
  return out
}

fn __diff_label(prefix, path, fallback) {
  if path == nil || trim(path) == "" {
    return fallback
  }
  return prefix + path
}

fn __diff_color_line(line, options) {
  if !(options?.color ?? false) {
    return line
  }
  let color_opts = {mode: options?.color_mode ?? "always"}
  if starts_with(line, "@@") {
    return ansi_info(line, color_opts)
  }
  if starts_with(line, "+++") || starts_with(line, "---") || starts_with(line, "diff ")
    || starts_with(line, "index ") {
    return ansi_dim(line, color_opts)
  }
  if starts_with(line, "+") {
    return ansi_success(line, color_opts)
  }
  if starts_with(line, "-") {
    return ansi_error(line, color_opts)
  }
  return line
}

/** Return a structured line diff `{changed, insertions, deletions, ops}`. */
pub fn diff_lines(before: string, after: string) -> dict {
  let old_lines = __diff_lines_of(before)
  let new_lines = __diff_lines_of(after)
  let ops = __diff_ops(old_lines, new_lines)
  return __diff_stats(ops)
    + {ops: ops, old_lines: len(old_lines), new_lines: len(new_lines)}
}

/** Render a unified diff for two text values. */
pub fn unified_diff(before: string, after: string, options: DiffOptions = {}) -> string {
  let opts = options ?? {}
  let path = opts.path
  let old_label = opts.from_label ?? __diff_label("a/", path, "before")
  let new_label = opts.to_label ?? __diff_label("b/", path, "after")
  let result = diff_lines(before, after)
  if !result.changed {
    return ""
  }
  let context = opts.context
  let ops = __diff_compact_ops(result.ops, context)
  var lines = [
    "--- " + old_label,
    "+++ " + new_label,
    "@@ -1," + to_string(result.old_lines) + " +1," + to_string(result.new_lines) + " @@",
  ]
  for op in ops {
    if op.kind == "skip" {
      lines = lines.push("...")
    } else if op.kind == "equal" {
      lines = lines.push(" " + op.line)
    } else if op.kind == "delete" {
      lines = lines.push("-" + op.line)
    } else if op.kind == "insert" {
      lines = lines.push("+" + op.line)
    }
  }
  return join(lines.map({ line -> __diff_color_line(line, opts) }), "\n")
}

/** Apply ANSI coloring to an existing unified diff. */
pub fn colorize_diff(diff_text: string, options: DiffOptions = {}) -> string {
  let base = options ?? {}
  let opts = base + {color: true}
  return join(split(diff_text ?? "", "\n").map({ line -> __diff_color_line(line, opts) }), "\n")
}

/** Return a compact stat dict for two text values. */
pub fn diff_summary(before: string, after: string) -> dict {
  let result = diff_lines(before, after)
  return {
    changed: result.changed,
    insertions: result.insertions,
    deletions: result.deletions,
    old_lines: result.old_lines,
    new_lines: result.new_lines,
  }
}

/** Render per-file diff stats from entries with `{path, before, after}` or stat fields. */
pub fn render_diff_stat(entries: list, options = {}) -> string {
  var rows = []
  for entry in entries ?? [] {
    let stat = if entry?.before != nil || entry?.after != nil {
      diff_summary(entry?.before ?? "", entry?.after ?? "")
    } else {
      entry
    }
    rows = rows
      .push(
      {
        path: entry?.path ?? entry?.file ?? "(text)",
        insertions: stat?.insertions ?? 0,
        deletions: stat?.deletions ?? 0,
      },
    )
  }
  return render_table(
    rows,
    {
      columns: [
        {key: "path", header: "Path"},
        {key: "insertions", header: "+", align: "right"},
        {key: "deletions", header: "-", align: "right"},
      ],
      format: options?.format ?? "plain",
      empty: options?.empty ?? "",
    },
  )
}