@@ -206,69 +206,262 @@ struct DiffRenderState {
206206 bool [size_t ] visibleLines;
207207}
208208
209+ // / Represents a line-level diff operation.
210+ struct LineDiffOp {
211+ EditOp op;
212+ size_t lineNum; // Line number in original (for remove/equal) or new (for insert)
213+ }
214+
215+ // / Computes line-based diff using LCS (Longest Common Subsequence) approach.
216+ // / Returns an array of operations indicating which lines to keep, remove, or insert.
217+ LineDiffOp[] computeLineDiff (ref HeapString[] expectedLines, ref HeapString[] actualLines) @trusted nothrow {
218+ LineDiffOp[] result;
219+ size_t expLen = expectedLines.length;
220+ size_t actLen = actualLines.length;
221+
222+ if (expLen == 0 && actLen == 0 ) {
223+ return result;
224+ }
225+
226+ if (expLen == 0 ) {
227+ foreach (i; 0 .. actLen) {
228+ result ~= LineDiffOp(EditOp.insert, i);
229+ }
230+ return result;
231+ }
232+
233+ if (actLen == 0 ) {
234+ foreach (i; 0 .. expLen) {
235+ result ~= LineDiffOp(EditOp.remove, i);
236+ }
237+ return result;
238+ }
239+
240+ // Build LCS table
241+ size_t [][] lcs;
242+ lcs.length = expLen + 1 ;
243+ foreach (i; 0 .. expLen + 1 ) {
244+ lcs[i].length = actLen + 1 ;
245+ }
246+
247+ foreach (i; 1 .. expLen + 1 ) {
248+ foreach (j; 1 .. actLen + 1 ) {
249+ if (linesEqual(expectedLines[i - 1 ], actualLines[j - 1 ])) {
250+ lcs[i][j] = lcs[i - 1 ][j - 1 ] + 1 ;
251+ } else if (lcs[i - 1 ][j] >= lcs[i][j - 1 ]) {
252+ lcs[i][j] = lcs[i - 1 ][j];
253+ } else {
254+ lcs[i][j] = lcs[i][j - 1 ];
255+ }
256+ }
257+ }
258+
259+ // Backtrack to build the diff
260+ LineDiffOp[] reversed;
261+ size_t i = expLen;
262+ size_t j = actLen;
263+
264+ while (i > 0 || j > 0 ) {
265+ if (i > 0 && j > 0 && linesEqual(expectedLines[i - 1 ], actualLines[j - 1 ])) {
266+ reversed ~= LineDiffOp(EditOp.equal, i - 1 );
267+ i-- ;
268+ j-- ;
269+ } else if (j > 0 && (i == 0 || lcs[i][j - 1 ] >= lcs[i - 1 ][j])) {
270+ reversed ~= LineDiffOp(EditOp.insert, j - 1 );
271+ j-- ;
272+ } else {
273+ reversed ~= LineDiffOp(EditOp.remove, i - 1 );
274+ i-- ;
275+ }
276+ }
277+
278+ // Reverse to get correct order
279+ foreach_reverse (idx; 0 .. reversed.length) {
280+ result ~= reversed[idx];
281+ }
282+
283+ return result;
284+ }
285+
286+ // / A block of consecutive diff operations of the same type.
287+ struct DiffBlock {
288+ EditOp op;
289+ size_t [] lineIndices;
290+ }
291+
292+ // / Groups consecutive diff operations into blocks.
293+ DiffBlock[] groupIntoBlocks (ref LineDiffOp[] ops) @trusted nothrow {
294+ DiffBlock[] blocks;
295+
296+ if (ops.length == 0 ) {
297+ return blocks;
298+ }
299+
300+ DiffBlock current;
301+ current.op = ops[0 ].op;
302+ current.lineIndices ~= ops[0 ].lineNum;
303+
304+ foreach (i; 1 .. ops.length) {
305+ if (ops[i].op == current.op) {
306+ current.lineIndices ~= ops[i].lineNum;
307+ } else {
308+ if (current.op != EditOp.equal) {
309+ blocks ~= current;
310+ }
311+ current.op = ops[i].op;
312+ current.lineIndices = [ops[i].lineNum];
313+ }
314+ }
315+
316+ if (current.op != EditOp.equal) {
317+ blocks ~= current;
318+ }
319+
320+ return blocks;
321+ }
322+
323+ // / Holds information about a change block with its context.
324+ struct ChangeBlockWithContext {
325+ size_t firstChangeLine; // First changed line index in expected (for remove) or actual (for insert)
326+ size_t lastChangeLine; // Last changed line index
327+ EditOp op;
328+ size_t [] lineIndices;
329+ }
330+
331+ // / Builds change blocks with position information for context lookup.
332+ ChangeBlockWithContext[] buildChangeBlocksWithContext (ref LineDiffOp[] ops) @trusted nothrow {
333+ ChangeBlockWithContext[] result;
334+
335+ if (ops.length == 0 ) {
336+ return result;
337+ }
338+
339+ ChangeBlockWithContext current;
340+ current.op = ops[0 ].op;
341+ current.lineIndices ~= ops[0 ].lineNum;
342+ current.firstChangeLine = ops[0 ].lineNum;
343+ current.lastChangeLine = ops[0 ].lineNum;
344+
345+ foreach (i; 1 .. ops.length) {
346+ if (ops[i].op == current.op) {
347+ current.lineIndices ~= ops[i].lineNum;
348+ current.lastChangeLine = ops[i].lineNum;
349+ } else {
350+ if (current.op != EditOp.equal) {
351+ result ~= current;
352+ }
353+ current.op = ops[i].op;
354+ current.lineIndices = [ops[i].lineNum];
355+ current.firstChangeLine = ops[i].lineNum;
356+ current.lastChangeLine = ops[i].lineNum;
357+ }
358+ }
359+
360+ if (current.op != EditOp.equal) {
361+ result ~= current;
362+ }
363+
364+ return result;
365+ }
366+
209367// / Sets a user-friendly line-by-line diff on the evaluation result.
210- // / Shows lines that differ between expected and actual values .
368+ // / Uses line-based diff algorithm and groups changes into readable blocks with context .
211369void setMultilineDiff (ref Evaluation evaluation) @trusted nothrow {
212- // Unescape the serialized strings before diffing
370+ enum CONTEXT_LINES = 2 ;
371+
213372 auto expectedUnescaped = unescapeString(evaluation.expectedValue.strValue);
214373 auto actualUnescaped = unescapeString(evaluation.currentValue.strValue);
215374
216- // Split into lines
217375 auto expectedLines = splitLines(expectedUnescaped);
218376 auto actualLines = splitLines(actualUnescaped);
219377
220378 if (expectedLines.length == 0 && actualLines.length == 0 ) {
221379 return ;
222380 }
223381
224- // Build the diff output
382+ auto lineDiff = computeLineDiff(expectedLines, actualLines);
383+ auto blocks = buildChangeBlocksWithContext(lineDiff);
384+
385+ if (blocks.length == 0 ) {
386+ return ;
387+ }
388+
225389 auto diffBuffer = HeapString.create(4096 );
226390 diffBuffer.put(" \n\n Diff:\n " );
227391
228- size_t maxLines = expectedLines.length > actualLines.length ? expectedLines.length : actualLines.length;
229- bool hasChanges = false ;
392+ foreach (blockIdx; 0 .. blocks.length) {
393+ auto block = blocks[blockIdx];
394+
395+ // Add separator and title at the start of each block
396+ if (blockIdx > 0 ) {
397+ diffBuffer.put(" \n ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n " );
398+ }
399+
400+ if (block.op == EditOp.remove) {
401+ diffBuffer.put(" --- Expected (missing in actual) ---\n " );
402+ } else if (block.op == EditOp.insert) {
403+ diffBuffer.put(" +++ Actual (not in expected) +++\n " );
404+ }
405+ diffBuffer.put(" \n " );
406+
407+ // Determine context lines source based on operation type
408+ HeapString[]* contextSource;
409+ size_t firstIdx = block.firstChangeLine;
410+
411+ if (block.op == EditOp.remove) {
412+ contextSource = &expectedLines;
413+ } else {
414+ contextSource = &actualLines;
415+ }
230416
231- foreach (i; 0 .. maxLines) {
232- bool hasExpected = i < expectedLines.length;
233- bool hasActual = i < actualLines.length;
417+ // Show context lines before the change
418+ size_t contextStart = firstIdx > CONTEXT_LINES ? firstIdx - CONTEXT_LINES : 0 ;
419+ foreach (ctxIdx; contextStart .. firstIdx) {
420+ auto lineNum = formatLineNumber(ctxIdx + 1 );
421+ diffBuffer.put(lineNum[]);
422+ diffBuffer.put(" " );
423+ diffBuffer.put((* contextSource)[ctxIdx][]);
424+ diffBuffer.put(" \n " );
425+ }
234426
235- if (hasExpected && hasActual) {
236- // Both have this line - check if different
237- if ( ! linesEqual(expectedLines[i], actualLines[i]) ) {
238- hasChanges = true ;
239- auto lineNum = formatLineNumber(i + 1 );
427+ // Show the changed lines
428+ if (block.op == EditOp.remove) {
429+ foreach (idx; 0 .. block.lineIndices.length ) {
430+ size_t lineIdx = block.lineIndices[idx] ;
431+ auto lineNum = formatLineNumber(lineIdx + 1 );
240432 diffBuffer.put(lineNum[]);
241433 diffBuffer.put(" [-" );
242- diffBuffer.put(expectedLines[i ][]);
434+ diffBuffer.put(expectedLines[lineIdx ][]);
243435 diffBuffer.put(" -]\n " );
436+ }
437+ } else if (block.op == EditOp.insert) {
438+ foreach (idx; 0 .. block.lineIndices.length) {
439+ size_t lineIdx = block.lineIndices[idx];
440+ auto lineNum = formatLineNumber(lineIdx + 1 );
244441 diffBuffer.put(lineNum[]);
245442 diffBuffer.put(" [+" );
246- diffBuffer.put(actualLines[i ][]);
443+ diffBuffer.put(actualLines[lineIdx ][]);
247444 diffBuffer.put(" +]\n " );
248445 }
249- } else if (hasExpected) {
250- // Line only in expected (removed)
251- hasChanges = true ;
252- auto lineNum = formatLineNumber(i + 1 );
253- diffBuffer.put(lineNum[]);
254- diffBuffer.put(" [-" );
255- diffBuffer.put(expectedLines[i][]);
256- diffBuffer.put(" -]\n " );
257- } else if (hasActual) {
258- // Line only in actual (added)
259- hasChanges = true ;
260- auto lineNum = formatLineNumber(i + 1 );
446+ }
447+
448+ // Show context lines after the change
449+ size_t lastIdx = block.lastChangeLine;
450+ size_t contextEnd = lastIdx + 1 + CONTEXT_LINES ;
451+ if (contextEnd > (* contextSource).length) {
452+ contextEnd = (* contextSource).length;
453+ }
454+ foreach (ctxIdx; lastIdx + 1 .. contextEnd) {
455+ auto lineNum = formatLineNumber(ctxIdx + 1 );
261456 diffBuffer.put(lineNum[]);
262- diffBuffer.put(" [+ " );
263- diffBuffer.put(actualLines[i ][]);
264- diffBuffer.put(" +] \n " );
457+ diffBuffer.put(" " );
458+ diffBuffer.put(( * contextSource)[ctxIdx ][]);
459+ diffBuffer.put(" \n " );
265460 }
266461 }
267462
268- if (hasChanges) {
269- diffBuffer.put(" \n " );
270- evaluation.result.addText(diffBuffer[]);
271- }
463+ diffBuffer.put(" \n " );
464+ evaluation.result.addText(diffBuffer[]);
272465}
273466
274467// / Splits a HeapString into lines.
0 commit comments