UNPKG

@cowwoc/requirements

Version:

A fluent API for enforcing design contracts with automatic message generation.

441 lines 16 kB
import { diffChars } from "diff"; import stripAnsi from "strip-ansi"; import { DiffResult, Node16Colors, Node16MillionColors, Node256Colors, TerminalEncoding, TextOnly, AbstractColorWriter, AssertionError, EOS_MARKER, assert, internalValueToString, containsOnly, lastIndexOf, requireThatValueIsNotNull } from "../../internal.mjs"; /** * Improve the readability of diff by avoiding many diffs per word or short diffs in a short word. * * ```stdout * Good: * -----=====----- * -----+++++===== * =====-----+++++ * * Bad: * =====-----=====----- * +++++=====+++++===== * -----++++++----===== * * Good: * football * ----====++++ * ballroom * * Bad: * 123 * -+= * 133 * ``` * <p> * Bad deltas are replaced with a single `[DELETE actual, INSERT expected]` pair. */ class SimplifyDeltas { // A "word" is defined as one or more characters that are surrounded by word delimiters. // // \p{Zs} matches any Unicode whitespace: https://www.regular-expressions.info/unicode.html static WORD_DELIMITER = SimplifyDeltas.getWordDelimiter(); static getWordDelimiter() { const whitespace = "\\p{Zs}+"; const newline = "\r\n|[\r\n]"; const specialCharacters = "[\\[\\](){}/\\\\*+\\-#:;.]"; return new RegExp(whitespace + "|" + newline + "|" + specialCharacters, "u"); } /** * The deltas to process. * <ul> * <li>A word may span one or more deltas.</li> * <li>The first delta it appears in is called the "start delta".</li> * <li>The last delta it appears in is called the "end delta".</li> * <li>Any deltas in between are called the "middle deltas".</li> * <li>If a word is fully contained within a single delta, its start and end deltas are the same, and * it has no middle deltas.</li> * </ul> */ deltas = []; /** * The index of the start delta in the list of all deltas. */ indexOfStartDelta = 0; /** * The index of the end delta in the list of all deltas. */ indexOfEndDelta = 0; /** * The index of the word in the start delta. */ startOfWord = 0; /** * The index right after the last character of the word in the end delta. */ endOfWord = 0; /** * The index of the next word in the end delta. If there are no more words, points to the end of the * string. */ startOfNextWord = 0; /** * @param deltas - the deltas to update */ accept(deltas) { this.deltas = deltas; // We are looking for words that span multiple deltas. If the first delta contains multiple // words, we are interested in the last one. this.findFirstWord(); if (this.indexOfStartDelta === this.deltas.length) return; do { this.findEndOfWord(); this.updateDeltas(); } while (this.findNextWord()); } /** * Finds the first word. */ findFirstWord() { // Words start after a whitespace delimiter within an EQUAL delta. If none is found, the start // of the first delta acts as a word boundary. const delta = this.deltas[0]; this.indexOfStartDelta = 0; const match = lastIndexOf(delta.value, SimplifyDeltas.WORD_DELIMITER); if (match === null) this.startOfWord = 0; else this.startOfWord = match.end; } /** * Finds the end of the word. */ findEndOfWord() { // Words end at a whitespace delimiter found within an EQUAL delta. If none is found, the end of the // last delta acts as a word boundary. for (let i = this.indexOfStartDelta + 1; i < this.deltas.length; ++i) { const delta = this.deltas[i]; const isEqual = !delta.removed && !delta.added; if (isEqual) { const match = SimplifyDeltas.WORD_DELIMITER.exec(delta.value); if (match) { this.endOfWord = match.index; this.startOfNextWord = match.index + match[0].length; this.indexOfEndDelta = i; return; } } } this.indexOfEndDelta = this.deltas.length - 1; } /** * Update the deltas if necessary. */ updateDeltas() { assert(this.deltas.length !== 0, undefined, JSON.stringify(this.deltas, null, 2)); const deltasInWord = this.deltas.slice(this.indexOfStartDelta, this.indexOfEndDelta + 1); if (deltasInWord.length < 2) return; if (this.numberOfUnequalDeltas(deltasInWord) <= 2 && (this.shortestDelta(deltasInWord) >= 3 || this.longestWord(deltasInWord) >= 5)) { // Diff is already good return; } // Otherwise, replace the deltas with a single [DELETE, INSERT] pair const updatedDeltas = []; let actualBuilder = ""; let expectedBuilder = ""; [actualBuilder, expectedBuilder] = this.processStartDelta(actualBuilder, expectedBuilder, updatedDeltas); [actualBuilder, expectedBuilder] = this.processMiddleDeltas(actualBuilder, expectedBuilder); this.processEndDelta(actualBuilder, expectedBuilder, updatedDeltas); const deltasRemoved = deltasInWord.length - updatedDeltas.length; // Remove deltasInWord and insert updatedDeltas in its place: // https://stackoverflow.com/a/17511398/14731 this.deltas.splice(this.indexOfStartDelta, deltasInWord.length, ...updatedDeltas); this.indexOfEndDelta -= deltasRemoved; this.startOfNextWord -= this.endOfWord; } /** * @param deltas - a list of deltas * @returns the number of deltas whose type is not EQUAL */ numberOfUnequalDeltas(deltas) { let result = 0; for (const delta of deltas) { if (delta.removed || delta.added) ++result; } return result; } /** * Processes the start delta. * * @param actualBuilder - a buffer to insert the actual value of the word into * @param expectedBuilder - a buffer to insert the expected value of the word into * @param updatedDeltas - a list to insert updated deltas into * @returns the updated values of `actualBuilder` and `expectedBuilder` */ processStartDelta(actualBuilder, expectedBuilder, updatedDeltas) { const delta = this.deltas[this.indexOfStartDelta]; let actualWord; let expectedWord; let beforeWord; if (delta.added) { actualWord = ""; expectedWord = delta.value; beforeWord = ""; } else if (delta.removed) { const actual = delta.value; actualWord = actual.substring(this.startOfWord); expectedWord = ""; beforeWord = actual.substring(0, this.startOfWord); } else { const actual = delta.value; actualWord = actual.substring(this.startOfWord); expectedWord = actualWord; beforeWord = actual.substring(0, this.startOfWord); } actualBuilder += actualWord; expectedBuilder += expectedWord; if (this.startOfWord > 0) { updatedDeltas.push({ added: delta.added, removed: delta.removed, value: beforeWord }); } return [actualBuilder, expectedBuilder]; } /** * Processes the middle deltas. * @param actualBuilder - a buffer to insert the actual value of the word into * @param expectedBuilder - a buffer to insert the expected value of the word into * @returns the updated values of `actualBuilder` and `expectedBuilder` */ processMiddleDeltas(actualBuilder, expectedBuilder) { for (let i = this.indexOfStartDelta + 1; i < this.indexOfEndDelta; ++i) { const delta = this.deltas[i]; if (!delta.added) { // Deleted or equal actualBuilder += delta.value; } if (!delta.removed) { // Inserted or equal expectedBuilder += delta.value; } } return [actualBuilder, expectedBuilder]; } /** * Processes the end delta. * * @param actualBuilder - a buffer to insert the actual value of the word into * @param expectedBuilder - a buffer to insert the expected value of the word into * @param updatedDeltas - a list to insert updated deltas into */ processEndDelta(actualBuilder, expectedBuilder, updatedDeltas) { const delta = this.deltas[this.indexOfEndDelta]; // Extract the first word in the delta let actualWord; let expectedWord; if (delta.added) { actualWord = delta.value.substring(0, this.endOfWord); expectedWord = ""; } else if (delta.removed) { actualWord = ""; expectedWord = delta.value.substring(0, this.endOfWord); } else { // Equal actualWord = expectedWord = delta.value.substring(0, this.endOfWord); } actualBuilder += actualWord; expectedBuilder += expectedWord; const deleteActual = { value: actualBuilder, added: false, removed: true }; const insertExpected = { value: expectedBuilder, added: true, removed: false }; updatedDeltas.push(deleteActual); updatedDeltas.push(insertExpected); // Add the remaining part of the delta if (this.endOfWord < delta.value.length) { updatedDeltas.push({ added: delta.added, removed: delta.removed, value: delta.value.substring(this.endOfWord) }); } } /** * Finds the next word. * * @returns `false` if there are no more words to be found */ findNextWord() { this.indexOfStartDelta = this.indexOfEndDelta; if (this.indexOfStartDelta === this.deltas.length - 1) return false; // Similar logic as findFirstWord() const delta = this.deltas[this.indexOfStartDelta]; if (!delta.added && !delta.removed) { // Equal const result = lastIndexOf(delta.value, SimplifyDeltas.WORD_DELIMITER); if (result === null) { throw new Error(`Expecting result to be equal to indexOfNextWordInEndDelta (${this.startOfNextWord}) or later. delta.value: ${delta.value}`); } this.startOfWord = result.end; } return true; } /** * @param deltas - a list of deltas * @returns the length of the shortest delta */ shortestDelta(deltas) { assert(this.deltas.length !== 0, undefined, JSON.stringify(this.deltas, null, 2)); let result = Number.MAX_VALUE; for (const delta of deltas) result = Math.min(result, delta.value.length); return result; } /** * @param deltas - a list of deltas * @returns the length of the longest word (source or target) spanned by the deltas */ longestWord(deltas) { let lengthOfSource = 0; let lengthOfTarget = 0; for (const delta of deltas) { const length = delta.value.length; if (delta.added) lengthOfTarget += length; else if (delta.removed) lengthOfSource += length; else { lengthOfSource += length; lengthOfTarget += length; } } let result = Math.max(lengthOfSource, lengthOfTarget); // Trim text before the first delta and after the last delta result -= this.startOfWord; const lastDelta = deltas[deltas.length - 1]; const actual = lastDelta.value; result -= actual.length - this.startOfNextWord; return Math.max(0, result); } } /** * Generates a diff of two Strings. */ class DiffGenerator { encoding; paddingMarker; simplifyDeltas = new SimplifyDeltas(); /** * @param encoding - the terminal encoding * @throws AssertionError if `encoding` is `undefined` or `null` */ constructor(encoding) { requireThatValueIsNotNull(encoding, "encoding"); this.encoding = encoding; this.paddingMarker = this.getPaddingMarker(); } /** * @returns the padding character used to align values vertically */ getPaddingMarker() { switch (this.encoding) { case TerminalEncoding.NONE: return TextOnly.DIFF_PADDING; case TerminalEncoding.NODE_16_COLORS: case TerminalEncoding.NODE_256_COLORS: case TerminalEncoding.NODE_16MILLION_COLORS: return AbstractColorWriter.DIFF_PADDING; default: throw new AssertionError(internalValueToString(this.encoding)); } } /** * Generates the diff of two strings. * <p> * <b>NOTE</b>: Colors may be disabled when stdin or stdout are redirected. To override this * behavior, use {@link GlobalConfiguration.terminalEncoding}. * * @param actual - the actual value * @param expected - the expected value * @returns the calculated diff */ diff(actual, expected) { // Mark the end of the string to guard against cases that end with whitespace const actualWithEos = actual + EOS_MARKER; const expectedWithEos = expected + EOS_MARKER; const writer = this.createDiffWriter(); // diffChars() returns a list of deltas, where each delta is associated with a list of characters. const deltas = diffChars(actualWithEos, expectedWithEos); this.simplifyDeltas.accept(deltas); for (const delta of deltas) this.writeDelta(delta, writer); writer.flush(); return new DiffResult(writer.getActualLines(), writer.getDiffLines(), writer.getExpectedLines(), writer.getEqualLines()); } /** * Write a single delta. * * @param delta - a delta * @param writer - the writer to write into */ writeDelta(delta, writer) { if (delta.added) writer.writeInserted(delta.value); else if (delta.removed) writer.writeDeleted(delta.value); else writer.writeEqual(delta.value); } /** * @returns a new writer */ createDiffWriter() { switch (this.encoding) { case TerminalEncoding.NONE: return new TextOnly(); case TerminalEncoding.NODE_16_COLORS: return new Node16Colors(); case TerminalEncoding.NODE_256_COLORS: return new Node256Colors(); case TerminalEncoding.NODE_16MILLION_COLORS: return new Node16MillionColors(); default: throw new AssertionError(internalValueToString(this.encoding)); } } /** * @param line - a line * @returns true if `line` is empty once all colors and padding characters are removed */ isEmpty(line) { switch (this.encoding) { case TerminalEncoding.NONE: break; case TerminalEncoding.NODE_16_COLORS: case TerminalEncoding.NODE_256_COLORS: case TerminalEncoding.NODE_16MILLION_COLORS: { line = stripAnsi(line); break; } default: throw new AssertionError(internalValueToString(this.encoding)); } return containsOnly(line, this.paddingMarker); } } export { DiffGenerator }; //# sourceMappingURL=DiffGenerator.mjs.map