sugarcraft / sugar-diff
Unified-diff engine — LCS line diff, context hunks, GNU diff -u writer and scanner. Extracted from candy-code.
Requires
- php: ^8.3
- ext-mbstring: *
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 23:31:34 UTC
README
Unified-diff engine — LCS line diffing, context hunks, a GNU diff -u writer, and a line-number scanner. First-party: the engine and the scanner were extracted from candy-code (Tools\Concerns\BuildsUnifiedDiff — now a delegating consumer — and the region model Tui\DiffGutter was ported from; the gutter still keeps its own copy today, and delegating it here is an open seam).
Installation
composer require sugarcraft/sugar-diff
Role
Diff::compute()— prefix/suffix-trimmed LCS over lines (same algorithmic guarantees as the candy-code trait:>=tie-break deletes first,maxLcsCellsbudget falls back to a whole-middle replace block, reported by thedegradedflag), producing numberedLinerows grouped intoHunks.Writer\UnifiedFormat— rendersdiff -ushaped text:--- / +++headers,@@ -a[,b] +c[,d] @@hunk headers (GNU count elision included), optional\ No newline at end of filemarkers and/dev/nullempty-side labels. Path labels are control-byte-safe (quoted like git'score.quotePath).Writer\SideBySideFormat— pairs one diff into side-by-side[left?, right?]rows, line by line, for split viewers:rows(Diff)from the hunk model,fromUnified(string|list<string>)from already-rendered patch text viaReader\UnifiedScan(both return the same rows for the same change). Leftover removals/additions stand alone; width and colour stay with the caller.Reader\UnifiedScan— walks any rendered unified diff and tells a viewer which old/new line each row points at, honouring each hunk's declared counts and classifying every row with aReader\ScanRowKind(the gutter model the candy-code TUI was built from), without owning any view code.Writer\SideBySideRow/Reader\ScannedLine— the immutable value rows of the two render/scan halves.
Quickstart
use SugarCraft\Diff\Diff; use SugarCraft\Diff\DiffOptions; use SugarCraft\Diff\Reader\UnifiedScan; $before = "Hello World\n"; $after = "Hello PHP\n"; $diff = Diff::compute($before, $after); $diff->isEmpty(); // false $diff->addedLines(); // 1 $diff->removedLines(); // 1 $patchText = $diff->unified('file.txt'); echo $patchText; // --- a/file.txt // +++ b/file.txt // @@ -1 +1 @@ // -Hello World // +Hello PHP // Options are immutable + fluent; defaults reproduce the candy-code trait's // hunk bodies and grouping (headers are GNU-faithful — see Fidelity notes). $wide = Diff::compute($before, $after, DiffOptions::new() ->withContextLines(10) ->withIgnoreWhitespace() ->withNoNewlineMarker() ->withDevNullOnEmptySide()); // Row model for renderers: foreach ($diff->lines as $line) { // $line->kind (LineKind enum), $line->text, $line->oldNumber, $line->newNumber // $line->marker(), $line->toUnifiedRow() } // Number the rows of an existing patch (e.g. for gutter display): foreach (UnifiedScan::lines($patchText) as $row) { // $row->old, $row->new, $row->fileHeader, $row->kind (ScanRowKind enum) }
API surface
| Class | Purpose |
|---|---|
Diff |
compute() entry point; lines, hunks, options, beforeCount/afterCount, beforeHasFinalNewline/afterHasFinalNewline, degraded; isEmpty(), addedLines(), removedLines(), unified($oldPath, ?$newPath = null) (second argument names the new side only — the before side uses the first), hunkText() |
DiffOptions |
new(); withContextLines() (DEFAULT_CONTEXT_LINES = 3), withMaxLcsCells() (DEFAULT_MAX_LCS_CELLS = 250 000), withIgnoreWhitespace(), withIgnoreCase(), withNoNewlineMarker(), withDevNullOnEmptySide() |
Hunk |
header(), lines(), removedCount()/addedCount() (-/+ rows only), the oldStart/oldLines/newStart/newLines anchors |
Line / LineKind |
One numbered row (verbatim text, side numbers, marker(), toUnifiedRow()); eq/del/ins vocabulary |
Writer\UnifiedFormat |
write() / hunksOnly() statics behind Diff::unified() / Diff::hunkText(); NO_NEWLINE_ROW |
Writer\SideBySideFormat / Writer\SideBySideRow |
rows() / fromUnified() pairing into [left?, right?] value rows for split layouts; SideBySideRow::isPaired() |
Reader\UnifiedScan |
lines() / fileHeaders() / rowsOf() row walks with declared-count budgets and fail-closed boundaries; MAX_LINE_NUMBER_DIGITS |
Reader\ScannedLine / Reader\ScanRowKind |
Per-row (old, new, fileHeader, kind) region model; row classification enum |
Non-visual engine: no ANSI, no rendering, no demos — view layers (gutters, coloring) consume the model and stay downstream. Sides accept a raw string or a list<string> (one element = one line; an element containing "\n" is refused — file() callers pass FILE_IGNORE_NEW_LINES). The model is eagerly materialised: compute() allocates the full Line/Hunk row set, and addedLines()/removedLines() re-walk it per call.
Rendering safely
Hunk row text ships exactly what the sides held — the engine never strips control bytes from content, because content is the caller's payload. A surface painting diffs of untrusted files should sanitize or escape those bytes for its own output channel (the TUI gutter clips to width; a web view HTML-escapes). The one thing the writer will not do is let a path label forge protocol rows: labels containing control bytes, " or \ are C-quoted git-core.quotePath-style, so every emitted header stays one physical line.
Fidelity notes
- Default options reproduce
candy-code'sBuildsUnifiedDiffhunk bodies and hunk grouping byte-for-byte; the header lines deliberately diverge (GNU-faithful — next bullet), and the\ No newlinemarkers and/dev/nulllabels are opt-in GNU affordances the trait never emitted. - Header printing is GNU-faithful (oracle-verified vs
/usr/bin/diff -U0): a zero-length side anchors at the line the hunk sits after (@@ -1,0 +2 @@mid-file,@@ -0,0 +1 @@at the head) and keeps its,0, and a single-line range drops its count (@@ -4 +4 @@, not-4,1 +4,1) — like GNUdiff -uand git. - git appends an optional function-context heading after the closing
@@; this engine has no enclosing-function notion, emits none, and the scanner ignores anything there. - File headers are git-style
--- a/<path>/+++ b/<path>with no timestamps; GNU's own shape is bare paths tab-separated from a date column. - A line's newline termination is invisible to the line diff:
"a\n"vs"a"compare equal and produce no hunks at all. git instead surfaces that change by rewriting the file's last row with\ No newline at end of file; this engine keeps git's counting but gates git's marker behindwithNoNewlineMarker()— without it, a terminator-only change is silent (thebeforeHasFinalNewline/afterHasFinalNewlinefacts are exposed so a caller can surface it). With the marker ON, patches for unterminated files are applyable as-is (GNU patch semantics); with it OFF they silently gain a trailing newline on apply. - CRLF vs LF differ in every line under default exact comparison (
\ris content);withIgnoreWhitespace()folds\raway — deliberately stronger than git's--strip-trailing-cr. - Zero-length sides anchor at the line the hunk sits AFTER, so at the start of a file the header reads
@@ -0,0 +1 @@— a caret-style anchor, not a row. - Binary files and rename/mode/CIM headers are out of scope:
git diff --binarypayloads and extended headers (similarity index,old mode, …) are not modelled — extended header lines scan as file headers, and binary content would diff as text by design. Colour is likewise left to the consuming surface (see candy-code'sTui\DiffGutterand webDiffView). - This library produces and reads patch text; it does not apply patches.
patch-style hunk fuzz, reversal detection and the--batch/already-applied behaviours are outside its scope — feed the output to a real applier if you need them.
Terminology, one place: old side = removed side = left; new side = added side = right.
License
MIT