parseUnifiedDiff function

DiffDocument parseUnifiedDiff(
  1. String source, {
  2. int? maxLineLength = 1000,
})

Parses a unified diff into sanitized DiffLine rows.

Implementation

DiffDocument parseUnifiedDiff(String source, {int? maxLineLength = 1000}) {
  final rows = <DiffLine>[];
  var fileIndex = -1;
  var sawGitHeader = false;
  var hunkIndex = -1;
  int? currentHunkIndex;
  var additionCount = 0;
  var deletionCount = 0;
  String? oldPath;
  String? newPath;
  int? oldCursor;
  int? newCursor;
  // Unconsumed old/new-side lines promised by the current hunk's `@@` header.
  // They still help describe hunk size, but an understated count must not end
  // the body early: once a hunk is open we stay in it until the next `@@`,
  // `diff --git`, or a non-body line (not +/-/space-prefixed and not blank).
  var remainingOld = 0;
  var remainingNew = 0;

  void addRow({
    required DiffLineKind kind,
    required String text,
    int? oldLine,
    int? newLine,
  }) {
    final formatted = _formatDiffLine(kind, text, maxLineLength);
    rows.add(
      DiffLine(
        index: rows.length,
        kind: kind,
        text: formatted.safeText,
        displayText: formatted.displayText,
        fileIndex: fileIndex >= 0 ? fileIndex : null,
        hunkIndex: currentHunkIndex,
        oldLine: oldLine,
        newLine: newLine,
        oldPath: oldPath,
        newPath: newPath,
        outputSanitized: formatted.sanitized,
        outputTruncated: formatted.truncated,
        outputOriginalLength: text.length,
      ),
    );
  }

  final rawLines = source.split('\n');
  if (rawLines.isNotEmpty && rawLines.last.isEmpty) rawLines.removeLast();
  String lineAt(int index) {
    if (index < 0 || index >= rawLines.length) return '';
    final raw = rawLines[index];
    return raw.endsWith('\r') ? raw.substring(0, raw.length - 1) : raw;
  }

  for (var lineIndex = 0; lineIndex < rawLines.length; lineIndex++) {
    final line = lineAt(lineIndex);
    // A `--- x` / `+++ y` / `@@` run is a file header even mid-body. The open
    // hunk alone cannot decide it: plain `diff -u` (no `diff --git` line) puts
    // the next file's headers directly after the previous hunk's last body
    // line, and reading them as a deletion and an addition swallowed every
    // file after the first, inflated the counts, and left oldPath/newPath
    // pointing at file 1. The counters cannot decide it either — that is what
    // an understated `@@ -1,1 +1,1 @@` breaks.
    // ...and only for a file that did NOT announce itself with `diff --git`.
    // A git diff already has an unambiguous file boundary, so mid-body the
    // lookahead can only be wrong there — a deletion of `-- x` followed by an
    // addition of `++ y` and then the next `@@` (SQL, Lua, Haskell and Ada
    // comments all produce that shape) was being reclassified as a header
    // pair, deleting two real edits from the rendered diff.
    final startsHeaderRun =
        !sawGitHeader &&
        line.startsWith('--- ') &&
        lineAt(lineIndex + 1).startsWith('+++ ') &&
        _hunkPattern.hasMatch(lineAt(lineIndex + 2));
    // Hunk membership tracks the open hunk, not the remaining counters — an
    // understated `@@ -1,1 +1,1 @@` still has body lines after the counters
    // hit zero, and those must stay edits rather than demoting to metadata.
    final inHunkBody = currentHunkIndex != null;
    // `diff --git` and `@@` never carry a +/-/space prefix, so a bare one at
    // column 0 is unambiguous even mid-hunk — a new file/hunk resets the body.
    if (line.startsWith('diff --git ')) {
      sawGitHeader = true;
      fileIndex += 1;
      oldPath = null;
      newPath = null;
      oldCursor = null;
      newCursor = null;
      remainingOld = 0;
      remainingNew = 0;
      currentHunkIndex = null;
      addRow(kind: DiffLineKind.fileHeader, text: line);
      continue;
    }
    // '---'/'+++' are file headers only outside a hunk body; inside one an
    // identical prefix is a deleted/added line whose content starts with -/+.
    if ((!inHunkBody || startsHeaderRun) && line.startsWith('--- ')) {
      oldPath = _normalizeDiffPath(line.substring(4));
      // A header run reached from inside a hunk body is the next file in a
      // plain `diff -u`, which has no `diff --git` to bump the index. Without
      // this every file in such a diff shared fileIndex 0, so anything that
      // groups by file — the file list, per-file collapse, jump-to-file —
      // merged them all into one.
      if (startsHeaderRun && inHunkBody) {
        fileIndex += 1;
      } else if (fileIndex < 0) {
        fileIndex = 0;
      }
      currentHunkIndex = null;
      addRow(kind: DiffLineKind.fileHeader, text: line);
      continue;
    }
    // The `+++` half of a run whose `---` was just taken as a file header.
    final continuesHeaderRun =
        line.startsWith('+++ ') &&
        rows.isNotEmpty &&
        rows.last.kind == DiffLineKind.fileHeader &&
        rows.last.text.startsWith('--- ');
    if ((!inHunkBody || continuesHeaderRun) && line.startsWith('+++ ')) {
      newPath = _normalizeDiffPath(line.substring(4));
      if (fileIndex < 0) fileIndex = 0;
      currentHunkIndex = null;
      addRow(kind: DiffLineKind.fileHeader, text: line);
      continue;
    }
    final hunk = _hunkPattern.firstMatch(line);
    if (hunk != null) {
      hunkIndex += 1;
      oldCursor = int.parse(hunk.group(1)!);
      newCursor = int.parse(hunk.group(3)!);
      // Groups 2/4 are the old/new line counts (absent ⇒ 1); they bound the
      // hunk body so the parser knows when '---'/'+++' become headers again.
      remainingOld = hunk.group(2) != null ? int.parse(hunk.group(2)!) : 1;
      remainingNew = hunk.group(4) != null ? int.parse(hunk.group(4)!) : 1;
      if (fileIndex < 0) fileIndex = 0;
      currentHunkIndex = hunkIndex;
      addRow(kind: DiffLineKind.hunkHeader, text: line);
      continue;
    }
    // '\ No newline at end of file' annotates the previous line and consumes
    // no old/new line, so it is handled regardless of the remaining counts.
    if (line.startsWith(r'\ ')) {
      addRow(kind: DiffLineKind.marker, text: line);
      continue;
    }
    if (inHunkBody && line.startsWith('+')) {
      final currentNew = newCursor;
      addRow(kind: DiffLineKind.addition, text: line, newLine: currentNew);
      if (newCursor != null) newCursor += 1;
      additionCount += 1;
      if (remainingNew > 0) remainingNew -= 1;
      continue;
    }
    // `git format-patch` ends a patch with an email signature: a `-- ` line,
    // then the git version. Once the hunk's line counts are spent, that line
    // ends the hunk below; it does not delete the old file's next line.
    final signature = line == '-- ' && remainingOld == 0 && remainingNew == 0;
    if (inHunkBody && line.startsWith('-') && !signature) {
      final currentOld = oldCursor;
      addRow(kind: DiffLineKind.deletion, text: line, oldLine: currentOld);
      if (oldCursor != null) oldCursor += 1;
      deletionCount += 1;
      if (remainingOld > 0) remainingOld -= 1;
      continue;
    }
    if (inHunkBody && line.startsWith(' ')) {
      final currentOld = oldCursor;
      final currentNew = newCursor;
      addRow(
        kind: DiffLineKind.context,
        text: line,
        oldLine: currentOld,
        newLine: currentNew,
      );
      if (oldCursor != null) oldCursor += 1;
      if (newCursor != null) newCursor += 1;
      if (remainingOld > 0) remainingOld -= 1;
      if (remainingNew > 0) remainingNew -= 1;
      continue;
    }
    // A blank mid-hunk line IS a context line — an empty one, whose single
    // leading space got stripped somewhere between the generator and here
    // (routine in emailed and pasted diffs). It consumes one old and one new
    // line like any other context row; leaving the cursors alone made every
    // row after it off by one and let two rows claim the same old line.
    if (inHunkBody && line.isEmpty) {
      final currentOld = oldCursor;
      final currentNew = newCursor;
      addRow(
        kind: DiffLineKind.context,
        text: line,
        oldLine: currentOld,
        newLine: currentNew,
      );
      if (oldCursor != null) oldCursor += 1;
      if (newCursor != null) newCursor += 1;
      if (remainingOld > 0) remainingOld -= 1;
      if (remainingNew > 0) remainingNew -= 1;
      continue;
    }
    // Non-body line ends the open hunk (next `@@` / `diff --git` already reset
    // above). Remaining counters clear so a later '---'/'+++' is a file header.
    if (inHunkBody) {
      remainingOld = 0;
      remainingNew = 0;
    }
    currentHunkIndex = null;
    addRow(kind: DiffLineKind.metadata, text: line);
  }

  final fileCount = rows
      .where((row) => row.kind == DiffLineKind.fileHeader)
      .map((row) => row.fileIndex)
      .whereType<int>()
      .toSet()
      .length;
  return DiffDocument(
    rows: List<DiffLine>.unmodifiable(rows),
    fileCount: fileCount,
    hunkCount: hunkIndex + 1,
    additionCount: additionCount,
    deletionCount: deletionCount,
  );
}