Skip to content

Instantly share code, notes, and snippets.

@rodydavis
Created December 24, 2025 04:00
Show Gist options
  • Select an option

  • Save rodydavis/a2ca57bb064fae96ad7dab3102a8e95f to your computer and use it in GitHub Desktop.

Select an option

Save rodydavis/a2ca57bb064fae96ad7dab3102a8e95f to your computer and use it in GitHub Desktop.
Myers Diff in Dart
import 'dart:typed_data';
import 'package:collection/collection.dart';
enum DiffType { insert, delete, equal }
class DiffOperation<T> {
final DiffType type;
final T data;
final int
index; // Index in newList (for insert) or oldList (for delete/equal)
DiffOperation(this.type, this.data, this.index);
@override
String toString() =>
'$type: ${data.toString().substring(0, 20)}... (@$index)';
}
/// A generic-free typedef for the custom hasher to maximize performance
typedef JsonHasher = int Function(Map<String, dynamic> item);
class FastJsonDiffer {
static const _deepEquality = DeepCollectionEquality();
// POOLING: Reusable buffers to prevent GC thrashing during high-freq updates
Int32List _vBuffer = Int32List(1024);
// Storing history for the traceback
// We reuse the list container, but we must allocate new Int32Lists for snapshots
final List<Int32List> _traceBuffer = [];
/// Main Diff Method.
///
/// [keyGenerator] (Optional): Provide this for maximum speed.
/// instead of comparing every field, return a hash of specific fields
/// (e.g., `(i) => Object.hash(i['id'], i['version'])`).
List<DiffOperation<Map<String, dynamic>>> diff(
List<Map<String, dynamic>> oldList,
List<Map<String, dynamic>> newList, {
JsonHasher? keyGenerator,
}) {
// 1. Identity Check (Optimization)
if (identical(oldList, newList)) return [];
if (oldList.isEmpty && newList.isEmpty) return [];
// 2. Generate Proxies (Hashes)
// If no keyGenerator is provided, we default to expensive Deep Equality
final hasher = keyGenerator ?? (item) => _deepEquality.hash(item);
final oldHashes = _generateHashes(oldList, hasher);
final newHashes = _generateHashes(newList, hasher);
// 3. Run Myers Algorithm on Ints
final rawOps = _diffInts(oldHashes, newHashes);
// 4. Rehydrate (Map indices back to actual Objects)
return rawOps.map((op) {
switch (op.type) {
case DiffType.insert:
return DiffOperation(DiffType.insert, newList[op.index], op.index);
case DiffType.delete:
return DiffOperation(DiffType.delete, oldList[op.index], op.index);
case DiffType.equal:
return DiffOperation(DiffType.equal, oldList[op.index], op.index);
}
}).toList();
}
Int32List _generateHashes(
List<Map<String, dynamic>> list,
JsonHasher hasher,
) {
final int len = list.length;
final buffer = Int32List(len);
for (var i = 0; i < len; i++) {
buffer[i] = hasher(list[i]);
}
return buffer;
}
/// The Core Myers Logic specialized for Int32List.
/// This runs purely on stack primitives and typed arrays.
List<DiffOperation<int>> _diffInts(Int32List oldList, Int32List newList) {
final n = oldList.length;
final m = newList.length;
final max = n + m;
final requiredSize = 2 * max + 1;
// Grow buffer if necessary
if (_vBuffer.length < requiredSize) {
_vBuffer = Int32List(requiredSize);
}
_vBuffer.fillRange(0, requiredSize, -1);
_traceBuffer.clear();
_vBuffer[max] = 0;
for (var d = 0; d <= max; d++) {
// Snapshot current V state for traceback.
_traceBuffer.add(Int32List.fromList(_vBuffer.sublist(0, requiredSize)));
for (var k = -d; k <= d; k += 2) {
final kIndex = max + k;
int x;
// Choose move: Down (Insertion) or Right (Deletion)
if (d == 0) {
x = 0;
} else if (k == -d ||
(k != d && _vBuffer[kIndex - 1] < _vBuffer[kIndex + 1])) {
x = _vBuffer[kIndex + 1];
} else {
x = _vBuffer[kIndex - 1] + 1;
}
int y = x - k;
// Snake: Move diagonal as long as hashes match
while (x < n && y < m && oldList[x] == newList[y]) {
x++;
y++;
}
_vBuffer[kIndex] = x;
// Check for completion
if (x >= n && y >= m) {
return _buildScript(oldList, newList, d, max);
}
}
}
return [];
}
List<DiffOperation<int>> _buildScript(
Int32List oldList,
Int32List newList,
int d,
int maxOffset,
) {
var script = <DiffOperation<int>>[];
var x = oldList.length;
var y = newList.length;
for (var i = d; i > 0; i--) {
final k = x - y;
final kIndex = maxOffset + k;
final prevV = _traceBuffer[i]; // FIXED: Use trace[i]
final kMinus1 = kIndex - 1;
final kPlus1 = kIndex + 1;
int prevKIndex;
final bool pickInsert;
// Robust selection logic
if (k == -i) {
pickInsert = true;
} else if (k == i) {
pickInsert = false;
} else if (prevV[kMinus1] == -1) {
pickInsert = true;
} else if (prevV[kPlus1] == -1) {
pickInsert = false;
} else {
pickInsert = prevV[kMinus1] < prevV[kPlus1];
}
if (pickInsert) {
prevKIndex = kPlus1;
} else {
prevKIndex = kMinus1;
}
final prevX = prevV[prevKIndex];
// Assert specific invariants
assert(
prevX >= 0,
"Myers invariant violated: prevX should effectively never be -1 (sentinel) when accessed from valid path.",
);
assert(
x >= prevX,
"Myers invariant violated: x ($x) cannot be less than prevX ($prevX)",
);
final isInsert = prevKIndex == kPlus1;
final startX = isInsert ? prevX : prevX + 1;
// Add Snakes (Equal)
while (x > startX) {
assert(x > 0, "Backtracking snake cannot go below 0 for x");
script.add(DiffOperation(DiffType.equal, 0, x - 1));
x--;
y--;
}
// Add Edit
if (isInsert) {
assert(y > 0, "Insert operation must have y index > 0 (y=$y)");
script.add(DiffOperation(DiffType.insert, 0, y - 1));
y--;
} else {
assert(x > 0, "Delete operation must have x index > 0 (x=$x)");
script.add(DiffOperation(DiffType.delete, 0, x - 1));
x--;
}
}
// Flush remaining snakes
while (x > 0 && y > 0) {
script.add(DiffOperation(DiffType.equal, 0, x - 1));
x--;
y--;
}
assert(
x == 0 && y == 0,
"Backtrack should end at 0,0. Ended at x=$x, y=$y. Traceback logic is flowed.",
);
return script.reversed.toList();
}
}
void main() {
final differ = FastJsonDiffer();
// Scenario: API Response or Game State
final oldData = [
{'id': 1, 'val': 'A', 'meta': 'complex_obj'},
{'id': 2, 'val': 'B', 'meta': 'complex_obj'},
{'id': 3, 'val': 'C', 'meta': 'complex_obj'},
];
final newData = [
{'id': 1, 'val': 'A', 'meta': 'complex_obj'},
{'id': 3, 'val': 'Z', 'meta': 'complex_obj'}, // Modified (val C -> Z)
{'id': 4, 'val': 'D', 'meta': 'complex_obj'}, // Inserted
];
print('--- 1. Default (Deep Equality) ---');
// Good for correctness, slower for large lists.
final opsDefault = differ.diff(oldData, newData);
for (var op in opsDefault) {
if (op.type != DiffType.equal) print(op);
}
// Expected:
// Delete: {id: 2...}
// Delete: {id: 3, val: C...} (Because val changed, hash changed)
// Insert: {id: 3, val: Z...}
// Insert: {id: 4...}
print('\n--- 2. Optimized (ID + Val Check) ---');
// Great for high-frequency. We tell the differ EXACTLY what constitutes a change.
// Here we ignore the 'meta' field entirely.
final opsFast = differ.diff(
oldData,
newData,
keyGenerator: (map) => Object.hash(map['id'], map['val']),
);
for (var op in opsFast) {
if (op.type != DiffType.equal) print(op);
}
print('\n--- 3. Ultra Fast (ID Only - Move Detection) ---');
// If we only care if the ID exists (e.g. for list animations where content updates later)
final opsIdOnly = differ.diff(
oldData,
newData,
keyGenerator: (map) => map['id'] as int, // Direct int cast is fastest
);
for (var op in opsIdOnly) {
if (op.type != DiffType.equal) print(op);
}
}
import 'dart:math';
import 'package:test/test.dart';
import 'myers_diff.dart';
void main() {
final differ = FastJsonDiffer();
final oldData = [
{'id': 1, 'val': 'A', 'meta': 'complex_obj'},
{'id': 2, 'val': 'B', 'meta': 'complex_obj'},
{'id': 3, 'val': 'C', 'meta': 'complex_obj'},
];
final newData = [
{'id': 1, 'val': 'A', 'meta': 'complex_obj'},
{'id': 3, 'val': 'Z', 'meta': 'complex_obj'}, // Modified (val C -> Z)
{'id': 4, 'val': 'D', 'meta': 'complex_obj'}, // Inserted
];
group('FastJsonDiffer Scenarios', () {
test('Default (Deep Equality) detects content changes', () {
// Good for correctness, slower for large lists.
final ops = differ.diff(oldData, newData);
// Filtering out 'equal' ops to check changes
final changes = ops.where((op) => op.type != DiffType.equal).toList();
// Expected changes:
// 1. Delete id:2
// 2. Delete id:3 (old version)
// 3. Insert id:3 (new version)
// 4. Insert id:4
expect(changes.length, equals(4));
expect(
changes.any((op) => op.type == DiffType.delete && op.data['id'] == 2),
isTrue,
);
expect(
changes.any((op) => op.type == DiffType.insert && op.data['id'] == 4),
isTrue,
);
// ID 3 changed, so it should appear as both delete and insert
expect(
changes.any(
(op) =>
op.type == DiffType.delete &&
op.data['id'] == 3 &&
op.data['val'] == 'C',
),
isTrue,
);
expect(
changes.any(
(op) =>
op.type == DiffType.insert &&
op.data['id'] == 3 &&
op.data['val'] == 'Z',
),
isTrue,
);
});
test(
'Optimized (ID + Val Check) behaves like Deep Equality for relevant fields',
() {
// Here we ignore the 'meta' field entirely, but 'val' changed so result is same as above.
final ops = differ.diff(
oldData,
newData,
keyGenerator: (map) => Object.hash(map['id'], map['val']),
);
final changes = ops.where((op) => op.type != DiffType.equal).toList();
expect(changes.length, equals(4));
expect(
changes.any((op) => op.type == DiffType.delete && op.data['id'] == 2),
isTrue,
);
expect(
changes.any((op) => op.type == DiffType.insert && op.data['id'] == 4),
isTrue,
);
// ID 3 changed val
expect(
changes.any((op) => op.type == DiffType.delete && op.data['id'] == 3),
isTrue,
);
expect(
changes.any((op) => op.type == DiffType.insert && op.data['id'] == 3),
isTrue,
);
},
);
test('Ultra Fast (ID Only) detects moves/existence only', () {
// If we only care if the ID exists (e.g. for list animations where content updates later)
final ops = differ.diff(
oldData,
newData,
keyGenerator: (map) => map['id'] as int,
);
final changes = ops.where((op) => op.type != DiffType.equal).toList();
// Expected changes:
// 1. Delete id:2
// 2. Insert id:4
// Node 3 should be EQUAL because ID matches
expect(changes.length, equals(2));
expect(
changes.any((op) => op.type == DiffType.delete && op.data['id'] == 2),
isTrue,
);
expect(
changes.any((op) => op.type == DiffType.insert && op.data['id'] == 4),
isTrue,
);
// Ensure id:3 is considered Equal
final id3Ops = ops.where((op) => op.data['id'] == 3).toList();
expect(id3Ops.length, equals(1));
expect(id3Ops.first.type, equals(DiffType.equal));
});
});
group('Fuzz Tests', () {
test('Randomized Diff/Patch Consistency (500 Iterations)', () {
final r = Random(42);
for (var i = 0; i < 500; i++) {
// 1. Generate two random lists
// We make listB a mutation of listA to ensure some overlap
final listA = _generateRandomList(r, size: r.nextInt(50) + 10);
final listB = _mutateList(r, listA);
// 2. Diff
// Using "Ultra Fast" (ID only) mode since it's cleaner for simple integer maps
// or just standard map equality if we use defaults.
// Let's use standard map equality for robustness.
final ops = differ.diff(listA, listB);
// 3. Reconstruct List B from Ops
final reconstructed = <Map<String, dynamic>>[];
for (final op in ops) {
switch (op.type) {
case DiffType.equal:
case DiffType.insert:
reconstructed.add(op.data);
break;
case DiffType.delete:
// Deletions are skipped in the new list construction
break;
}
}
// 4. Verify
try {
expect(reconstructed, equals(listB));
} catch (e) {
print('Fuzz Failure at iteration $i');
print('List A (len ${listA.length}): $listA');
print('List B (len ${listB.length}): $listB');
print('Ops: $ops');
rethrow;
}
}
});
});
}
// --- Fuzz Utils ---
List<Map<String, dynamic>> _generateRandomList(Random r, {required int size}) {
return List.generate(size, (index) {
return {
'id': r.nextInt(1000), // Random IDs, duplicates possible
'val': r.nextInt(100),
'content': _randomString(r),
};
});
}
List<Map<String, dynamic>> _mutateList(
Random r,
List<Map<String, dynamic>> original,
) {
final clone = List<Map<String, dynamic>>.from(original);
final mutations = r.nextInt(10) + 1; // 1 to 10 mutations
for (var m = 0; m < mutations; m++) {
if (clone.isEmpty) {
clone.add(_generateSingleItem(r));
continue;
}
final action = r.nextInt(3);
if (action == 0) {
// Insert
final index = r.nextInt(clone.length + 1);
clone.insert(index, _generateSingleItem(r));
} else if (action == 1) {
// Delete
final index = r.nextInt(clone.length);
clone.removeAt(index);
} else {
// Modify (Replace item)
final index = r.nextInt(clone.length);
clone[index] = _generateSingleItem(r);
}
}
return clone;
}
Map<String, dynamic> _generateSingleItem(Random r) {
return {
'id': r.nextInt(1000),
'val': r.nextInt(100),
// 'content': _randomString(r), // Keep simple
};
}
String _randomString(Random r) {
const chars = 'abcdefghijklmnopqrstuvwxyz';
return List.generate(5, (_) => chars[r.nextInt(chars.length)]).join();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment