UNPKG

git-split-diffs-api

Version:
1,320 lines (1,291 loc) 39.3 kB
// src/index.ts import { Readable as Readable2, Writable as Writable2 } from "stream"; import chalk from "chalk"; import { exec } from "child_process"; import terminalSize from "terminal-size"; import * as util from "util"; // src/context.ts import * as shiki from "shiki"; // src/SpannedString.ts import wcwidth from "wcwidth"; var SpannedString = class _SpannedString { constructor(string, spans, nextId) { this._string = ""; this._spanMarkers = [void 0]; this._nextId = 0; this._string = string; this._spanMarkers = spans; this._nextId = nextId; } static create() { return new _SpannedString("", [void 0], 0); } addSpan(startIndex, endIndex, attribute) { this._spanMarkers[startIndex] = this._spanMarkers[startIndex] ?? []; this._spanMarkers[startIndex].push({ id: this._nextId, attribute, isStart: true }); this._spanMarkers[endIndex] = this._spanMarkers[endIndex] ?? []; this._spanMarkers[endIndex].push({ id: this._nextId, attribute, isStart: false }); this._nextId++; return this; } appendString(string, ...attributes) { const startIndex = this._string.length; const endIndex = startIndex + string.length; this._string += string; this._spanMarkers = this._spanMarkers.concat(new Array(string.length)); for (const attribute of attributes) { this.addSpan(startIndex, endIndex, attribute); } return this; } appendSpannedString(other) { this._string = this._string.concat(other._string); const spanMarkers = this._spanMarkers.concat( new Array(other._spanMarkers.length - 1) ); const overlappingSpanIndex = this._spanMarkers.length - 1; for (let otherIndex = 0; otherIndex < other._spanMarkers.length; otherIndex++) { const otherSpans = other._spanMarkers[otherIndex]; if (otherSpans) { const index = otherIndex + overlappingSpanIndex; spanMarkers[index] = spanMarkers[index] ?? []; spanMarkers[index]?.push( ...otherSpans.map((span) => ({ ...span, // Remap ids to avoid collisions id: span.id + this._nextId })) ); } } this._spanMarkers = spanMarkers; this._nextId = this._nextId + other._nextId; return this; } slice(startIndex, endIndex = this._string.length) { if (startIndex < 0 || endIndex < 0) { throw new Error("Invalid start or end index"); } if (startIndex > this._string.length) { startIndex = this._string.length; } if (endIndex > this._string.length) { endIndex = this._string.length; } let index = 0; const activeSpansById = /* @__PURE__ */ new Map(); const updatedActiveSpans = () => { for (const span of this._spanMarkers[index] ?? []) { if (span.isStart) { activeSpansById.set(span.id, span); } else { activeSpansById.delete(span.id); } } }; const getActiveSpans = () => { return [...activeSpansById.values()]; }; const newSpanMarkers = new Array( endIndex + 1 - startIndex ); while (index < startIndex) { updatedActiveSpans(); index++; } newSpanMarkers[0] = getActiveSpans().map((span) => ({ ...span })); while (index < endIndex) { const spans = this._spanMarkers[index]; updatedActiveSpans(); if (spans) { const newIndex = index - startIndex; newSpanMarkers[newIndex] = newSpanMarkers[newIndex] ?? []; newSpanMarkers[newIndex].push(...spans); } index++; } newSpanMarkers[endIndex - startIndex] = getActiveSpans().map( (span) => ({ ...span, isStart: false }) ); return new _SpannedString( this._string.slice(startIndex, endIndex), newSpanMarkers, this._nextId ); } fillWidth(width, fillString) { const paddingLength = width - this.getWidth(); if (paddingLength > 0) { this.appendString("".padEnd(paddingLength, fillString)); } return this; } getString() { return this._string; } /** * Returns the screen width of the string in columns, i.e. accounting for * characters that may occupy more than one character width in the terminal. */ getWidth() { return wcwidth(this._string); } /** * Returns the screen width per character. */ getCharWidths() { const charWidths = []; for (const char of this._string) { charWidths.push(wcwidth(char)); } return charWidths; } *iterSubstrings() { const activeSpansById = /* @__PURE__ */ new Map(); function getActiveAttributes() { return Array.from(activeSpansById.values()).sort((a, b) => a.id - b.id).map((span) => span.attribute); } let lastIndex = 0; for (let spanIndex = 0; spanIndex <= this._string.length; spanIndex++) { const spans = this._spanMarkers[spanIndex]; if (spans === void 0 || spans.length === 0) { continue; } if (spanIndex > lastIndex) { yield [ this._string.slice(lastIndex, spanIndex), getActiveAttributes() ]; } for (const span of spans) { if (span.isStart) { activeSpansById.set(span.id, span); } else { activeSpansById.delete(span.id); } } lastIndex = spanIndex; } if (lastIndex < this._string.length) { yield [this._string.slice(lastIndex), getActiveAttributes()]; } } }; // src/themes.ts import * as assert from "assert"; import * as path from "path"; import * as fs from "fs"; var ThemeColorName = /* @__PURE__ */ ((ThemeColorName2) => { ThemeColorName2["DEFAULT_COLOR"] = "DEFAULT_COLOR"; ThemeColorName2["COMMIT_HEADER_LABEL_COLOR"] = "COMMIT_HEADER_LABEL_COLOR"; ThemeColorName2["COMMIT_HEADER_COLOR"] = "COMMIT_HEADER_COLOR"; ThemeColorName2["COMMIT_SHA_COLOR"] = "COMMIT_SHA_COLOR"; ThemeColorName2["COMMIT_AUTHOR_COLOR"] = "COMMIT_AUTHOR_COLOR"; ThemeColorName2["COMMIT_DATE_COLOR"] = "COMMIT_DATE_COLOR"; ThemeColorName2["COMMIT_TITLE_COLOR"] = "COMMIT_TITLE_COLOR"; ThemeColorName2["COMMIT_MESSAGE_COLOR"] = "COMMIT_MESSAGE_COLOR"; ThemeColorName2["BORDER_COLOR"] = "BORDER_COLOR"; ThemeColorName2["FILE_NAME_COLOR"] = "FILE_NAME_COLOR"; ThemeColorName2["HUNK_HEADER_COLOR"] = "HUNK_HEADER_COLOR"; ThemeColorName2["DELETED_WORD_COLOR"] = "DELETED_WORD_COLOR"; ThemeColorName2["DELETED_LINE_COLOR"] = "DELETED_LINE_COLOR"; ThemeColorName2["DELETED_LINE_NO_COLOR"] = "DELETED_LINE_NO_COLOR"; ThemeColorName2["INSERTED_WORD_COLOR"] = "INSERTED_WORD_COLOR"; ThemeColorName2["INSERTED_LINE_COLOR"] = "INSERTED_LINE_COLOR"; ThemeColorName2["INSERTED_LINE_NO_COLOR"] = "INSERTED_LINE_NO_COLOR"; ThemeColorName2["UNMODIFIED_LINE_COLOR"] = "UNMODIFIED_LINE_COLOR"; ThemeColorName2["UNMODIFIED_LINE_NO_COLOR"] = "UNMODIFIED_LINE_NO_COLOR"; ThemeColorName2["MISSING_LINE_COLOR"] = "MISSING_LINE_COLOR"; return ThemeColorName2; })(ThemeColorName || {}); function hexToRgba(hex) { assert.ok(hex.length === 7 || hex.length === 9 || hex.length === 4, hex); hex = hex.slice(1); let hexNo = parseInt(hex, 16); const bits = hex.length === 3 ? 4 : 8; let a = 255; if (hex.length === 8) { a = hexNo & 255; hexNo >>>= bits; } const b = hexNo & 255; hexNo >>>= bits; const g = hexNo & 255; hexNo >>>= bits; const r = hexNo & 255; return { r, g, b, a }; } function mergeColors(a, b) { if (!b || b.a === 0) { return a; } if (!a) { return b; } const t = 1 - b.a / 255; return { r: b.r * (1 - t) + a.r * t, g: b.g * (1 - t) + a.g * t, b: b.b * (1 - t) + a.b * t, a: b.a * (1 - t) + a.a * t }; } function mergeModifiers(a, b) { if (a && b) { return a.concat(b); } return a ?? b; } function mergeThemeColors(a, b) { return { color: mergeColors(a.color, b.color), backgroundColor: mergeColors(a.backgroundColor, b.backgroundColor), modifiers: mergeModifiers(a.modifiers, b.modifiers) }; } function reduceThemeColors(colors) { let themeColor = {}; for (let i = colors.length - 1; i >= 0; i--) { themeColor = mergeThemeColors(themeColor, colors[i]); } return themeColor; } function parseColorDefinition(definition) { return { color: definition.color ? hexToRgba(definition.color) : void 0, backgroundColor: definition.backgroundColor ? hexToRgba(definition.backgroundColor) : void 0, modifiers: definition.modifiers }; } function loadThemeDefinition(themesDir, themeName) { return JSON.parse( fs.readFileSync(path.join(themesDir, `${themeName}.json`)).toString() ); } function loadTheme(themesDir, themeName) { const themeDefinition = loadThemeDefinition(themesDir, themeName); const theme = { SYNTAX_HIGHLIGHTING_THEME: themeDefinition.SYNTAX_HIGHLIGHTING_THEME }; const themeColorNames = Object.keys(ThemeColorName); for (const variableName of themeColorNames) { const value = themeDefinition[variableName]; if (!value) { assert.fail(`${variableName} is missing in theme`); } theme[variableName] = parseColorDefinition(value); } return theme; } // src/formattedString.ts var FormattedString = class extends SpannedString { }; function T() { return FormattedString.create(); } function applyFormatting(context, string) { const { CHALK, DEFAULT_COLOR } = context; let formattedString = ""; for (const [substring, colors] of string.iterSubstrings()) { let formattedSubstring = substring; const themeColor = reduceThemeColors([...colors, DEFAULT_COLOR]); const { color, backgroundColor, modifiers } = themeColor; if (color) { formattedSubstring = CHALK.rgb( Math.floor(color.r), Math.floor(color.g), Math.floor(color.b) )(formattedSubstring); } if (backgroundColor) { formattedSubstring = CHALK.bgRgb( Math.floor(backgroundColor.r), Math.floor(backgroundColor.g), Math.floor(backgroundColor.b) )(formattedSubstring); } if (modifiers) { for (const modifier of modifiers) { formattedSubstring = CHALK[modifier](formattedSubstring); } } formattedString += formattedSubstring; } return formattedString; } // src/context.ts async function getContextForConfig(config, chalk2, screenWidth) { const SCREEN_WIDTH = screenWidth; const HORIZONTAL_SEPARATOR = T().fillWidth(SCREEN_WIDTH, "\u2500").addSpan(0, SCREEN_WIDTH, config.BORDER_COLOR); let HIGHLIGHTER = void 0; if (config.SYNTAX_HIGHLIGHTING_THEME) { HIGHLIGHTER = await shiki.createHighlighter({ themes: [config.SYNTAX_HIGHLIGHTING_THEME], langs: [] }); } return { ...config, CHALK: chalk2, SCREEN_WIDTH, HORIZONTAL_SEPARATOR, HIGHLIGHTER }; } // src/getGitConfig.ts import path2 from "path"; import { fileURLToPath } from "url"; var DEFAULT_MIN_LINE_WIDTH = 80; var DEFAULT_THEME_DIRECTORY = path2.resolve( path2.dirname(fileURLToPath(import.meta.url)), "..", "themes" ); var DEFAULT_THEME_NAME = "dark"; var GIT_CONFIG_KEY_PREFIX = "split-diffs"; var GIT_CONFIG_LINE_REGEX = new RegExp( `${GIT_CONFIG_KEY_PREFIX}\\.([^=]+)=(.*)` ); function extractFromGitConfigString(configString) { const rawConfig = {}; for (const line of configString.trim().split("\n")) { const match = line.match(GIT_CONFIG_LINE_REGEX); if (!match) { continue; } const [, key, value] = match; rawConfig[key] = value; } return rawConfig; } function getGitConfig(configString) { const rawConfig = extractFromGitConfigString(configString); let minLineWidth = DEFAULT_MIN_LINE_WIDTH; try { const parsedMinLineWidth = parseInt(rawConfig["min-line-width"], 10); if (!isNaN(parsedMinLineWidth)) { minLineWidth = parsedMinLineWidth; } } catch { } return { MIN_LINE_WIDTH: minLineWidth, WRAP_LINES: rawConfig["wrap-lines"] !== "false", HIGHLIGHT_LINE_CHANGES: rawConfig["highlight-line-changes"] !== "false", THEME_DIRECTORY: rawConfig["theme-directory"] ?? DEFAULT_THEME_DIRECTORY, THEME_NAME: rawConfig["theme-name"] ?? DEFAULT_THEME_NAME, SYNTAX_HIGHLIGHTING_THEME: rawConfig["syntax-highlighting-theme"] }; } // src/transformContentsStreaming.ts import stream, { Readable } from "stream"; // src/iterLinesFromReadable.ts var NEWLINE_REGEX = /\n/g; function* yieldLinesFromString(string) { string = string.replace(/\r/g, ""); let lastIndex = 0; let match; while (match = NEWLINE_REGEX.exec(string)) { yield string.slice(lastIndex, match.index); lastIndex = match.index + match[0].length; } return string.slice(lastIndex); } async function* iterlinesFromReadable(readable) { let string = ""; for await (const chunk of readable) { string += chunk.toString(); string = yield* yieldLinesFromString(string); } yield string; } // src/iterReplaceTabsWithSpaces.ts var TAB_TO_SPACES = " "; async function* iterReplaceTabsWithSpaces(context, lines) { for await (const line of lines) { yield line.replace(/\t/g, TAB_TO_SPACES); } } // src/iterSideBySideDiffs.ts import ansiRegex from "ansi-regex"; import * as assert2 from "assert"; // src/wrapSpannedStringByWord.ts var SPACE_REGEX = /\s/; function getLineBreaksForString(string, charWidths, width) { const lineBreaks = []; let budget = width; let curLineEnd = 0; function flushLine() { lineBreaks.push(curLineEnd); budget = width; } function pushWord(startIndex, endIndex) { let wordWidth = 0; for (let i = startIndex; i < endIndex; i++) { wordWidth += charWidths[i]; } if (wordWidth <= budget) { curLineEnd = endIndex; budget -= wordWidth; return; } if (wordWidth <= width) { flushLine(); curLineEnd = endIndex; budget -= wordWidth; return; } for (let i = startIndex; i < endIndex; i++) { const charLength = charWidths[i]; if (budget < charLength) { flushLine(); } budget -= charLength; curLineEnd++; } } let prevIndex = 0; let curIndex = 1; let prevIsSpace = SPACE_REGEX.test(string[prevIndex]); while (curIndex < string.length) { const isSpace = SPACE_REGEX.test(string[curIndex]); if (isSpace) { pushWord(prevIndex, curIndex); prevIndex = curIndex; } else if (prevIsSpace) { pushWord(prevIndex, curIndex); prevIndex = curIndex; } prevIsSpace = isSpace; curIndex++; } if (prevIndex < curIndex) { pushWord(prevIndex, curIndex); } if (budget < width) { flushLine(); } return lineBreaks; } function* wrapSpannedStringByWord(spannedString, width) { const string = spannedString.getString(); const charWidths = spannedString.getCharWidths(); const stringWidth = charWidths.reduce((a, b) => a + b, 0); if (stringWidth < width) { yield spannedString; return; } const lineBreaks = getLineBreaksForString(string, charWidths, width); let prevLineBreak = 0; for (const lineBreak of lineBreaks) { yield spannedString.slice(prevLineBreak, lineBreak); prevLineBreak = lineBreak; } if (prevLineBreak < stringWidth - 1) { yield spannedString.slice(prevLineBreak); } } // src/iterFitTextToWidth.ts function* iterFitTextToWidth(context, formattedString, width, backgroundColor) { if (context.WRAP_LINES) { for (const wrappedLine of wrapSpannedStringByWord( formattedString, width )) { wrappedLine.fillWidth(width); if (backgroundColor) { wrappedLine.addSpan(0, width, backgroundColor); } yield wrappedLine; } } else { const truncatedLine = formattedString.slice(0, width).fillWidth(width); if (backgroundColor) { truncatedLine.addSpan(0, width, backgroundColor); } yield truncatedLine; } } // src/iterFormatCommitBodyLine.ts function* iterFormatCommitBodyLine(context, line, isFirstLine) { const { COMMIT_TITLE_COLOR, COMMIT_MESSAGE_COLOR, SCREEN_WIDTH } = context; const formattedLine = T().appendString(line); if (isFirstLine) { formattedLine.addSpan(0, line.length, COMMIT_TITLE_COLOR); } yield* iterFitTextToWidth( context, formattedLine, SCREEN_WIDTH, COMMIT_MESSAGE_COLOR ); } // src/iterFormatCommitHeaderLine.ts function* iterFormatCommitHeaderLine(context, line) { const { COMMIT_HEADER_LABEL_COLOR, COMMIT_AUTHOR_COLOR, COMMIT_HEADER_COLOR, COMMIT_DATE_COLOR, COMMIT_SHA_COLOR, SCREEN_WIDTH } = context; const [label] = line.split(" ", 1); let labelColor; switch (label) { case "commit": labelColor = COMMIT_SHA_COLOR; break; case "Author:": labelColor = COMMIT_AUTHOR_COLOR; break; case "Date:": labelColor = COMMIT_DATE_COLOR; break; } const formattedLine = T().appendString(line).addSpan(0, label.length, COMMIT_HEADER_LABEL_COLOR); if (labelColor) { formattedLine.addSpan(0, SCREEN_WIDTH - label.length - 1, labelColor); } yield* iterFitTextToWidth( context, formattedLine, SCREEN_WIDTH, COMMIT_HEADER_COLOR ); } // src/iterFormatFileName.ts function* iterFormatFileName(context, fileNameA, fileNameB) { const { HORIZONTAL_SEPARATOR, INSERTED_LINE_COLOR, DELETED_LINE_COLOR, INSERTED_LINE_NO_COLOR, DELETED_LINE_NO_COLOR, FILE_NAME_COLOR, SCREEN_WIDTH } = context; yield HORIZONTAL_SEPARATOR; const formattedString = T().appendString(" \u25A0\u25A0 "); let fileNameLabel; if (!fileNameA) { formattedString.addSpan(1, 3, INSERTED_LINE_NO_COLOR).addSpan(1, 3, INSERTED_LINE_COLOR); fileNameLabel = fileNameB; } else if (!fileNameB) { formattedString.addSpan(1, 3, DELETED_LINE_NO_COLOR).addSpan(1, 3, DELETED_LINE_COLOR); fileNameLabel = fileNameA; } else if (fileNameA === fileNameB) { formattedString.addSpan(1, 2, DELETED_LINE_NO_COLOR).addSpan(2, 3, INSERTED_LINE_NO_COLOR).addSpan(1, 2, DELETED_LINE_COLOR).addSpan(2, 3, INSERTED_LINE_COLOR); fileNameLabel = fileNameA; } else { formattedString.addSpan(1, 2, DELETED_LINE_NO_COLOR).addSpan(2, 3, INSERTED_LINE_NO_COLOR).addSpan(1, 2, DELETED_LINE_COLOR).addSpan(2, 3, INSERTED_LINE_COLOR); fileNameLabel = `${fileNameA} -> ${fileNameB}`; } formattedString.appendString(fileNameLabel); yield* iterFitTextToWidth( context, formattedString, SCREEN_WIDTH, FILE_NAME_COLOR ); yield HORIZONTAL_SEPARATOR; } // src/highlightChangesInLine.ts import { diffWords } from "diff"; // src/zip.ts function* zip(...iterables) { const iterators = iterables.map((iterable) => iterable[Symbol.iterator]()); while (true) { const values = []; let hasMore = false; for (const iterator of iterators) { const { done, value } = iterator.next(); hasMore ||= !done; values.push(value); } if (!hasMore) { return; } yield values; } } async function* zipAsync(...iterables) { const iterators = iterables.map( (iterable) => iterable[Symbol.asyncIterator]() ); while (true) { const values = []; let hasMore = false; for (const iterator of iterators) { const { done, value } = await iterator.next(); hasMore ||= !done; values.push(value); } if (!hasMore) { return; } yield values; } } // src/highlightChangesInLine.ts var HIGHLIGHT_CHANGE_RATIO = 1; function getChangesInLine(context, lineA, lineB) { const { HIGHLIGHT_LINE_CHANGES } = context; if (!HIGHLIGHT_LINE_CHANGES || lineA === null || lineB === null) { return null; } const lineTextA = lineA.slice(1); const lineTextB = lineB.slice(1); const changes = diffWords(lineTextA, lineTextB, { ignoreCase: false, ignoreWhitespace: false }); let changedWords = 0; let totalWords = 0; for (const { added, removed, count } of changes) { if (added || removed) { changedWords += count ?? 0; } else { totalWords += count ?? 0; } } if (changedWords > totalWords * HIGHLIGHT_CHANGE_RATIO) { return null; } return changes; } function getChangesInLines(context, linesA, linesB) { const changes = []; for (const [lineA, lineB] of zip(linesA, linesB)) { changes.push(getChangesInLine(context, lineA ?? null, lineB ?? null)); } return changes; } function highlightChangesInLine(context, linePrefix, formattedLine, changes) { if (!changes) { return; } const { DELETED_WORD_COLOR, INSERTED_WORD_COLOR, UNMODIFIED_LINE_COLOR } = context; let wordColor; switch (linePrefix) { case "-": wordColor = DELETED_WORD_COLOR; break; case "+": wordColor = INSERTED_WORD_COLOR; break; default: wordColor = UNMODIFIED_LINE_COLOR; break; } let lineIndex = 0; for (const change of changes) { if (change.removed && linePrefix === "+") { continue; } if (change.added && linePrefix === "-") { continue; } if (change.removed || change.added) { formattedLine.addSpan( lineIndex, lineIndex + change.value.length, wordColor ); } lineIndex += change.value.length; } } // src/highlightSyntaxInLine.ts import path3 from "path"; import * as shiki2 from "shiki"; function parseShikiColor(token) { let modifiers; if (token.fontStyle !== void 0 && token.fontStyle !== shiki2.FontStyle.NotSet && token.fontStyle !== shiki2.FontStyle.None) { modifiers = []; if (token.fontStyle & shiki2.FontStyle.Bold) { modifiers.push("bold"); } if (token.fontStyle & shiki2.FontStyle.Italic) { modifiers.push("italic"); } if (token.fontStyle & shiki2.FontStyle.Underline) { modifiers.push("underline"); } } const themeColor = parseColorDefinition({ color: token.color, backgroundColor: token.bgColor, modifiers }); return themeColor; } async function highlightSyntaxInLine(line, fileName, highlighter, theme) { const language = path3.extname(fileName).slice(1); if (!shiki2.bundledLanguages[language]) { return; } await highlighter.loadLanguage(language); const { tokens } = highlighter.codeToTokens(line.getString(), { includeExplanation: false, lang: language, theme }); for (const token of tokens.flat()) { line.addSpan( token.offset, token.offset + token.content.length, parseShikiColor(token) ); } } // src/formatAndFitHunkLine.ts var LINE_NUMBER_WIDTH = 5; async function* formatAndFitHunkLine(context, lineWidth, fileName, lineNo, line, changes) { const { MISSING_LINE_COLOR, DELETED_LINE_COLOR, DELETED_LINE_NO_COLOR, INSERTED_LINE_COLOR, INSERTED_LINE_NO_COLOR, UNMODIFIED_LINE_COLOR, UNMODIFIED_LINE_NO_COLOR } = context; const blankLine = "".padStart(lineWidth); if (line === null || lineNo === 0) { yield T().appendString(blankLine, MISSING_LINE_COLOR); return; } const linePrefix = line.slice(0, 1); const lineText = line.slice(1); let lineColor; let lineNoColor; switch (linePrefix) { case "-": lineColor = DELETED_LINE_COLOR; lineNoColor = DELETED_LINE_NO_COLOR; break; case "+": lineColor = INSERTED_LINE_COLOR; lineNoColor = INSERTED_LINE_NO_COLOR; break; default: lineColor = UNMODIFIED_LINE_COLOR; lineNoColor = UNMODIFIED_LINE_NO_COLOR; break; } const lineTextWidth = lineWidth - 2 - 1 - 1 - LINE_NUMBER_WIDTH; let isFirstLine = true; const formattedLine = T().appendString(lineText); highlightChangesInLine(context, linePrefix, formattedLine, changes); if (context.HIGHLIGHTER && context.SYNTAX_HIGHLIGHTING_THEME) { await highlightSyntaxInLine( formattedLine, fileName, context.HIGHLIGHTER, context.SYNTAX_HIGHLIGHTING_THEME ); } for (const fittedLine of iterFitTextToWidth( context, formattedLine, lineTextWidth )) { const lineNoText = (isFirstLine ? lineNo.toString() : "").padStart(LINE_NUMBER_WIDTH) + " "; const wrappedLinePrefix = (isFirstLine ? linePrefix : "").padStart(2).padEnd(3); const hunkLine = T().appendString(lineNoText, lineNoColor).appendString(wrappedLinePrefix).appendSpannedString(fittedLine); hunkLine.addSpan(0, hunkLine.getString().length, lineColor); yield hunkLine; isFirstLine = false; } } // src/iterFormatHunkSplit.ts async function* iterFormatHunkSplit(context, hunkParts, lineChanges) { const { MISSING_LINE_COLOR } = context; const lineWidth = Math.floor(context.SCREEN_WIDTH / hunkParts.length); const blankLine = "".padStart(lineWidth); const lineNos = hunkParts.map((part) => part.startLineNo); const numDeletes = hunkParts.map(() => 0); for (const [changes, ...hunkPartLines] of zip( lineChanges, ...hunkParts.map((part) => part.lines) )) { hunkPartLines.forEach((hunkPartLine, i) => { const prefix = hunkPartLine?.slice(0, 1) ?? null; if (prefix === "-") { numDeletes[i]++; } else { lineNos[i] -= numDeletes[i]; numDeletes[i] = 0; } }); const formattedLineIterables = hunkPartLines.map( (hunkPartLine, i) => formatAndFitHunkLine( context, lineWidth, hunkParts[i].fileName, lineNos[i], hunkPartLine ?? null, changes ?? null ) ); const missingLine = T().appendString(blankLine, MISSING_LINE_COLOR); for await (const formattedLines of zipAsync( ...formattedLineIterables )) { const formattedLine = T(); for (const line of formattedLines) { formattedLine.appendSpannedString(line ?? missingLine); } yield formattedLine; } hunkPartLines.forEach((hunkPartLine, i) => { if (hunkPartLine !== null && hunkPartLine !== void 0) { lineNos[i]++; } }); } } // src/iterFormatHunkUnified.ts async function* iterFormatUnifiedDiffHunkUnified(context, hunkParts, lineChanges) { const lineWidth = context.SCREEN_WIDTH; const [ { fileName: fileNameA, lines: hunkLinesA }, { fileName: fileNameB, lines: hunkLinesB } ] = hunkParts; let [{ startLineNo: lineNoA }, { startLineNo: lineNoB }] = hunkParts; let indexA = 0, indexB = 0; while (indexA < hunkLinesA.length) { const hunkLineA = hunkLinesA[indexA]; const prefixA = hunkLineA?.slice(0, 1) ?? null; switch (prefixA) { case null: break; case "-": yield* formatAndFitHunkLine( context, lineWidth, fileNameA, lineNoA, hunkLineA, lineChanges[indexA] ); lineNoA++; break; default: while (indexB < indexA) { const hunkLineB = hunkLinesB[indexB]; if (hunkLineB !== null) { yield* formatAndFitHunkLine( context, lineWidth, fileNameB, lineNoB, hunkLineB, lineChanges[indexB] ); lineNoB++; } indexB++; } yield* formatAndFitHunkLine( context, lineWidth, fileNameA, lineNoA, hunkLineA, lineChanges[indexB] ); lineNoA++; lineNoB++; indexB++; } indexA++; } while (indexB < hunkLinesB.length) { const hunkLineB = hunkLinesB[indexB]; if (hunkLineB !== null) { yield* formatAndFitHunkLine( context, lineWidth, fileNameB, lineNoB, hunkLineB, lineChanges[indexB] ); lineNoB++; } indexB++; } } async function* iterFormatCombinedDiffHunkUnified(context, hunkParts, lineChanges) { const lineWidth = context.SCREEN_WIDTH; const { fileName, lines, startLineNo } = hunkParts[hunkParts.length - 1]; let lineNo = startLineNo; let numDeletes = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const prefix = line?.slice(0, 1) ?? null; if (prefix == "-") { numDeletes++; } else { lineNo -= numDeletes; numDeletes = 0; } yield* formatAndFitHunkLine( context, lineWidth, fileName, lineNo, line, lineChanges[i] ); lineNo++; } } // src/iterFormatHunk.ts async function* iterFormatHunk(context, diffType, hunkHeaderLine, hunkParts) { const { HUNK_HEADER_COLOR, SCREEN_WIDTH, MIN_LINE_WIDTH } = context; yield* iterFitTextToWidth( context, T().appendString(hunkHeaderLine), SCREEN_WIDTH, HUNK_HEADER_COLOR ); const changes = getChangesInLines( context, hunkParts[0].lines, hunkParts[1].lines ); const splitDiffs = SCREEN_WIDTH >= MIN_LINE_WIDTH * hunkParts.length; if (splitDiffs) { yield* iterFormatHunkSplit(context, hunkParts, changes); } else if (diffType === "unified-diff") { yield* iterFormatUnifiedDiffHunkUnified(context, hunkParts, changes); } else if (diffType === "combined-diff") { yield* iterFormatCombinedDiffHunkUnified(context, hunkParts, changes); } } // src/iterSideBySideDiffs.ts var ANSI_COLOR_CODE_REGEX = ansiRegex(); var BINARY_FILES_DIFF_REGEX = /^Binary files (?:a\/(.*)|\/dev\/null) and (?:b\/(.*)|\/dev\/null) differ$/; var COMBINED_HUNK_HEADER_START_REGEX = /^(@{2,}) /; async function* iterSideBySideDiffsFormatted(context, lines) { const { HORIZONTAL_SEPARATOR } = context; let state = "unknown"; let isFirstCommitBodyLine = false; let fileNameA = ""; let fileNameB = ""; function* yieldFileName() { yield* iterFormatFileName(context, fileNameA, fileNameB); } let hunkParts = []; let hunkHeaderLine = ""; async function* yieldHunk(diffType) { yield* iterFormatHunk(context, diffType, hunkHeaderLine, hunkParts); for (const hunkPart of hunkParts) { hunkPart.startLineNo = -1; hunkPart.lines = []; } } async function* flushPending() { if (state === "unified-diff" || state === "combined-diff") { yield* yieldFileName(); } else if (state === "unified-diff-hunk-body") { yield* yieldHunk("unified-diff"); } else if (state === "combined-diff-hunk-body") { yield* yieldHunk("combined-diff"); } } for await (const rawLine of lines) { const line = rawLine.replace(ANSI_COLOR_CODE_REGEX, ""); let nextState = null; if (line.startsWith("commit ")) { nextState = "commit-header"; } else if (state === "commit-header" && line.startsWith(" ")) { nextState = "commit-body"; } else if (line.startsWith("diff --git")) { nextState = "unified-diff"; } else if (line.startsWith("@@ ")) { nextState = "unified-diff-hunk-header"; } else if (state === "unified-diff-hunk-header") { nextState = "unified-diff-hunk-body"; } else if (line.startsWith("diff --cc") || line.startsWith("diff --combined")) { nextState = "combined-diff"; } else if (COMBINED_HUNK_HEADER_START_REGEX.test(line)) { nextState = "combined-diff-hunk-header"; } else if (state === "combined-diff-hunk-header") { nextState = "combined-diff-hunk-body"; } else if (state === "commit-body" && line.length > 0 && !line.startsWith(" ")) { nextState = "unknown"; } if (nextState) { yield* flushPending(); switch (nextState) { case "commit-header": if (state === "unified-diff-hunk-header" || state === "unified-diff-hunk-body") { yield HORIZONTAL_SEPARATOR; } break; case "unified-diff": fileNameA = ""; fileNameB = ""; break; case "unified-diff-hunk-header": hunkParts = [ { fileName: fileNameA, startLineNo: -1, lines: [] }, { fileName: fileNameB, startLineNo: -1, lines: [] } ]; break; case "commit-body": isFirstCommitBodyLine = true; break; } state = nextState; } switch (state) { case "unknown": { yield T().appendString(rawLine); break; } case "commit-header": { yield* iterFormatCommitHeaderLine(context, line); break; } case "commit-body": { yield* iterFormatCommitBodyLine( context, line, isFirstCommitBodyLine ); isFirstCommitBodyLine = false; break; } case "unified-diff": case "combined-diff": { if (line.startsWith("--- a/")) { fileNameA = line.slice("--- a/".length); } else if (line.startsWith("+++ b/")) { fileNameB = line.slice("+++ b/".length); } else if (line.startsWith("--- ")) { fileNameA = line.slice("--- ".length); if (fileNameA === "/dev/null") { fileNameA = ""; } } else if (line.startsWith("+++ ")) { fileNameB = line.slice("+++ ".length); if (fileNameB === "/dev/null") { fileNameB = ""; } } else if (line.startsWith("rename from ")) { fileNameA = line.slice("rename from ".length); } else if (line.startsWith("rename to ")) { fileNameB = line.slice("rename to ".length); } else if (line.startsWith("Binary files")) { const match = line.match(BINARY_FILES_DIFF_REGEX); if (match) { [, fileNameA, fileNameB] = match; } } break; } case "unified-diff-hunk-header": { const hunkHeaderStart = line.indexOf("@@ "); const hunkHeaderEnd = line.indexOf(" @@", hunkHeaderStart + 1); assert2.ok(hunkHeaderStart >= 0); assert2.ok(hunkHeaderEnd > hunkHeaderStart); const hunkHeader = line.slice( hunkHeaderStart + 3, hunkHeaderEnd ); hunkHeaderLine = line; const [aHeader, bHeader] = hunkHeader.split(" "); const [startAString] = aHeader.split(","); const [startBString] = bHeader.split(","); assert2.ok(startAString.startsWith("-")); hunkParts[0].startLineNo = parseInt(startAString.slice(1), 10); assert2.ok(startBString.startsWith("+")); hunkParts[1].startLineNo = parseInt(startBString.slice(1), 10); break; } case "unified-diff-hunk-body": { const [{ lines: hunkLinesA }, { lines: hunkLinesB }] = hunkParts; if (line.startsWith("-")) { hunkLinesA.push(line); } else if (line.startsWith("+")) { hunkLinesB.push(line); } else { while (hunkLinesA.length < hunkLinesB.length) { hunkLinesA.push(null); } while (hunkLinesB.length < hunkLinesA.length) { hunkLinesB.push(null); } hunkLinesA.push(line); hunkLinesB.push(line); } break; } case "combined-diff-hunk-header": { const match = COMBINED_HUNK_HEADER_START_REGEX.exec(line); assert2.ok(match); const hunkHeaderStart = match.index + match[0].length; const hunkHeaderEnd = line.lastIndexOf(" " + match[1]); assert2.ok(hunkHeaderStart >= 0); assert2.ok(hunkHeaderEnd > hunkHeaderStart); const hunkHeader = line.slice(hunkHeaderStart, hunkHeaderEnd); hunkHeaderLine = line; const fileRanges = hunkHeader.split(" "); hunkParts = []; for (let i = 0; i < fileRanges.length; i++) { const fileRange = fileRanges[i]; const [fileRangeStart] = fileRange.slice(1).split(","); hunkParts.push({ fileName: i === fileRanges.length - 1 ? fileNameB : fileNameA, startLineNo: parseInt(fileRangeStart, 10), lines: [] }); } break; } case "combined-diff-hunk-body": { const linePrefix = line.slice(0, hunkParts.length - 1); const lineSuffix = line.slice(hunkParts.length - 1); const isLineAdded = linePrefix.includes("+"); const isLineRemoved = linePrefix.includes("-"); let i = 0; while (i < hunkParts.length - 1) { const hunkPart = hunkParts[i]; const partPrefix = linePrefix[i]; if (isLineAdded) { if (partPrefix === "+") { hunkPart.lines.push(null); } else { hunkPart.lines.push("+" + lineSuffix); } } else if (isLineRemoved) { if (partPrefix === "-") { hunkPart.lines.push("-" + lineSuffix); } else { hunkPart.lines.push(null); } } else { hunkPart.lines.push(" " + lineSuffix); } i++; } if (isLineRemoved) { hunkParts[i].lines.push("-" + lineSuffix); } else if (isLineAdded) { hunkParts[i].lines.push("+" + lineSuffix); } else { hunkParts[i].lines.push(" " + lineSuffix); } break; } } } yield* flushPending(); } async function* iterSideBySideDiffs(context, lines) { for await (const formattedString of iterSideBySideDiffsFormatted( context, lines )) { yield applyFormatting(context, formattedString); } } // src/iterWithNewlines.ts import * as os from "os"; async function* iterWithNewlines(context, lines) { for await (const line of lines) { yield line + os.EOL; } } // src/transformContentsStreaming.ts function transformContentsStreaming(context, input, output) { return new Promise((resolve, reject) => { const transformedInput = Readable.from( iterWithNewlines( context, iterSideBySideDiffs( context, iterReplaceTabsWithSpaces( context, iterlinesFromReadable(input) ) ) ) ); stream.pipeline(transformedInput, output, (err) => { if (err) { switch (err.code) { case "EPIPE": break; default: reject(err); return; } } resolve(); }); }); } // src/getConfig.ts var CONFIG_DEFAULTS = { MIN_LINE_WIDTH: 80, WRAP_LINES: true, HIGHLIGHT_LINE_CHANGES: true }; function getConfig(gitConfig) { const theme = loadTheme(gitConfig.THEME_DIRECTORY, gitConfig.THEME_NAME); return { ...CONFIG_DEFAULTS, ...theme, ...gitConfig, SYNTAX_HIGHLIGHTING_THEME: gitConfig.SYNTAX_HIGHLIGHTING_THEME ?? theme.SYNTAX_HIGHLIGHTING_THEME }; } // src/index.ts var execAsync = util.promisify(exec); async function transform(input, userConfig, columns = terminalSize().columns) { const { THEME_NAME, ...restUserConfig } = userConfig; const { stdout: gitConfigString } = await execAsync("git config -l"); const gitConfig = getGitConfig(gitConfigString); if (THEME_NAME) { gitConfig.THEME_NAME = THEME_NAME; } const config = getConfig(gitConfig); const finalConfig = { ...config, ...restUserConfig }; const context = await getContextForConfig( finalConfig, chalk, columns ); let string = ""; await transformContentsStreaming( context, Readable2.from(input), new class extends Writable2 { write(chunk) { string += chunk.toString(); return true; } }() ); return string; } export { transform };