// 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}`.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: diff_lines(before, after)
*/
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.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: unified_diff(before, after, options)
*/
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.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: colorize_diff(diff_text, options)
*/
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.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: diff_summary(before, after)
*/
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,
}
}
/**
* Compare two source files with tree-sitter and return changed syntax-node
* spans. The result is for human review, not patch application: staging and
* apply paths should keep using line diffs.
*
* `options` may be a language string (`"rust"`) or a dict with `language`,
* `max_bytes`, `max_nodes`, and `max_graph_edges`. If parsing fails or a limit
* is exceeded, the host returns `result: "fallback"` with `mode: "line"` and
* a `line_diff` payload instead of throwing.
*
* @effects: [host]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: structural_diff("before.rs", "after.rs", "rust")
*/
pub fn structural_diff(path_a: string, path_b: string, options = nil) -> dict {
let opts = if type_of(options) == "string" {
{language: options}
} else {
options ?? {}
}
let payload = hostlib_ast_structural_diff((opts ?? {}).merge({path_a: path_a, path_b: path_b})) ?? {}
return payload
.merge(
{
ok: payload?.result ?? "" == "ok" || payload?.result ?? "" == "fallback",
structural: payload?.mode ?? "" == "structural",
provenance: {module: "std/diff", helper: "structural_diff"},
},
)
}
/**
* Render per-file diff stats from entries with `{path, before, after}` or stat fields.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: render_diff_stat(entries, options)
*/
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 ?? "",
},
)
}