Skip to content

Commit 4585a1a

Browse files
committed
feat: Enhance evaluation output with newline check and implement line-based diffing for better context in assertions
1 parent a71d445 commit 4585a1a

File tree

3 files changed

+249
-38
lines changed

3 files changed

+249
-38
lines changed

source/fluentasserts/core/evaluator.d

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ mixin template EvaluatorContextMethods() {
115115
}
116116

117117
operation(_evaluation);
118-
_evaluation.result.addText(".");
118+
if (!_evaluation.result.endsWithNewline()) {
119+
_evaluation.result.addText(".");
120+
}
119121

120122
if (Lifecycle.instance.keepLastEvaluation) {
121123
Lifecycle.instance.lastEvaluation = _evaluation;
@@ -191,7 +193,9 @@ mixin template EvaluatorContextMethods() {
191193
_evaluation.isEvaluated = true;
192194

193195
operation(_evaluation);
194-
_evaluation.result.addText(".");
196+
if (!_evaluation.result.endsWithNewline()) {
197+
_evaluation.result.addText(".");
198+
}
195199

196200
if (_evaluation.currentValue.throwable !is null) {
197201
throw _evaluation.currentValue.throwable;
@@ -345,7 +349,9 @@ mixin template EvaluatorContextMethods() {
345349
_evaluation.isEvaluated = true;
346350

347351
op(_evaluation);
348-
_evaluation.result.addText(".");
352+
if (!_evaluation.result.endsWithNewline()) {
353+
_evaluation.result.addText(".");
354+
}
349355

350356
if (_evaluation.currentValue.throwable !is null) {
351357
throw _evaluation.currentValue.throwable;

source/fluentasserts/operations/equality/equal.d

Lines changed: 228 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
211369
void 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\nDiff:\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.

source/fluentasserts/results/asserts.d

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ struct AssertResult {
133133
|| diff.length > 0 || extra.length > 0 || missing.length > 0;
134134
}
135135

136+
/// Returns true if the message ends with a newline (multiline output).
137+
bool endsWithNewline() nothrow @safe @nogc const {
138+
if (_messageCount == 0) {
139+
return false;
140+
}
141+
auto lastMsg = _messages[_messageCount - 1];
142+
if (lastMsg.text.length == 0) {
143+
return false;
144+
}
145+
return lastMsg.text[lastMsg.text.length - 1] == '\n';
146+
}
147+
136148
/// Formats a value for display, replacing special characters with glyphs.
137149
string formatValue(string value) nothrow inout {
138150
return value

0 commit comments

Comments
 (0)