blob: 2e5752feb54b141f4b04c2a69eaa304cf4948ee5 [file]
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {DiffInfo} from '../../../types/diff';
import {CommentSide, Side} from '../../../constants/constants';
import {CommentThread, PatchRange} from '../../../types/common';
import {isInBaseOfPatchRange} from '../../../utils/comment-util';
import {getDiffLines} from '../../../utils/diff-util';
import {htmlEscape} from '../../../utils/inner-html-util';
import {Marked, Token, Tokens} from 'marked';
export type DiffBlockStatus = 'unchanged' | 'added' | 'deleted' | 'modified';
export type MarkdownToken = Token & {
startLine?: number;
endLine?: number;
};
export interface AlignedDiffRow {
status: DiffBlockStatus;
leftToken?: MarkdownToken;
rightToken?: MarkdownToken;
leftHtml?: string;
rightHtml?: string;
leftStartLine?: number;
leftEndLine?: number;
rightStartLine?: number;
rightEndLine?: number;
}
export interface AlignedDiffRowWithThreads extends AlignedDiffRow {
leftThreads: CommentThread[];
rightThreads: CommentThread[];
}
export interface InlineDiffSegment {
text: string;
type: 'common' | 'added' | 'deleted';
}
/** Reconstruct the entire file content from diff chunks for the given side. */
export function reconstructFileContent(diff: DiffInfo, side: Side): string {
return getDiffLines(diff, side).join('\n');
}
/** Parse markdown text into top-level block tokens with line number metadata. */
export function parseMarkdownBlocks(markdown: string): MarkdownToken[] {
if (!markdown) return [];
const marked = new Marked();
const tokens = marked.lexer(markdown);
const result: MarkdownToken[] = [];
let curLine = 1;
for (const t of tokens) {
const raw = t.raw;
const newlineCount = (raw.match(/\n/g) || []).length;
const startLine = curLine;
const endLine = curLine + newlineCount - (raw.endsWith('\n') ? 1 : 0);
curLine += newlineCount;
if (t.type === 'space') {
continue;
}
(t as MarkdownToken).startLine = startLine;
(t as MarkdownToken).endLine = Math.max(startLine, endLine);
result.push(t as MarkdownToken);
}
return result;
}
/** Tokenize text into words, whitespace, and punctuation for fine-grained inline diffing. */
export function tokenizeWords(text: string): string[] {
return text.match(/\s+|[^\s\w]+|\w+/g) || [];
}
/** Compute LCS-based diff between two arrays of strings (e.g. words or lines). */
export function computeSequenceDiff(
seqA: string[],
seqB: string[]
): InlineDiffSegment[] {
const m = seqA.length;
const n = seqB.length;
// DP table for LCS lengths
const dp: number[][] = Array.from({length: m + 1}, () =>
new Array(n + 1).fill(0)
);
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (seqA[i] === seqB[j]) {
dp[i + 1][j + 1] = dp[i][j] + 1;
} else {
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
}
// Backtrack to build diff segments
const segments: InlineDiffSegment[] = [];
let i = m;
let j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && seqA[i - 1] === seqB[j - 1]) {
segments.unshift({text: seqA[i - 1], type: 'common'});
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
segments.unshift({text: seqB[j - 1], type: 'added'});
j--;
} else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
segments.unshift({text: seqA[i - 1], type: 'deleted'});
i--;
}
}
return segments;
}
/** Render a single marked token to standard HTML using marked. */
export function renderTokenToHtml(token?: Token): string {
if (!token) return '';
const marked = new Marked();
return marked.parse(token.raw, {async: false}) || '';
}
/** Render an inline diff for modified text in headings or paragraphs. */
export function renderInlineTextDiff(
textA: string,
textB: string,
wrapperTag = 'p'
): {leftHtml: string; rightHtml: string} {
const wordsA = tokenizeWords(textA);
const wordsB = tokenizeWords(textB);
const diff = computeSequenceDiff(wordsA, wordsB);
// Merge consecutive segments of the same type
const mergedSegments: InlineDiffSegment[] = [];
for (const seg of diff) {
const last = mergedSegments[mergedSegments.length - 1];
if (last && last.type === seg.type) {
last.text += seg.text;
} else {
mergedSegments.push({...seg});
}
}
let leftContent = '';
let rightContent = '';
for (const seg of mergedSegments) {
const escaped = htmlEscape(seg.text).toString();
if (seg.type === 'common') {
leftContent += escaped;
rightContent += escaped;
} else if (seg.type === 'deleted') {
leftContent += `<del class="diff-highlight-del">${escaped}</del>`;
} else if (seg.type === 'added') {
rightContent += `<ins class="diff-highlight-add">${escaped}</ins>`;
}
}
return {
leftHtml: `<${wrapperTag}>${leftContent}</${wrapperTag}>`,
rightHtml: `<${wrapperTag}>${rightContent}</${wrapperTag}>`,
};
}
/** Render a modified code block with line-by-line diff highlights. */
export function renderCodeBlockDiff(
tokenA: Tokens.Code,
tokenB: Tokens.Code
): {leftHtml: string; rightHtml: string} {
const linesA = tokenA.text.split('\n');
const linesB = tokenB.text.split('\n');
const lineDiff = computeSequenceDiff(linesA, linesB);
let leftLines = '';
let rightLines = '';
for (const seg of lineDiff) {
const escaped = htmlEscape(seg.text).toString();
if (seg.type === 'common') {
leftLines += `${escaped}\n`;
rightLines += `${escaped}\n`;
} else if (seg.type === 'deleted') {
leftLines += `<span class="diff-highlight-del">${escaped}</span>\n`;
} else if (seg.type === 'added') {
rightLines += `<span class="diff-highlight-add">${escaped}</span>\n`;
}
}
const langClass = tokenB.lang
? ` class="language-${htmlEscape(tokenB.lang)}"`
: '';
return {
leftHtml: `<pre><code${langClass}>${leftLines.trimEnd()}</code></pre>`,
rightHtml: `<pre><code${langClass}>${rightLines.trimEnd()}</code></pre>`,
};
}
/**
* Align base (A) and revision (B) markdown block tokens into rows.
* Matches exact blocks as anchors, pairs modified blocks of compatible types,
* and leaves unmatched blocks as added or deleted with empty partner cells.
*/
export function alignMarkdownTokens(
tokensA: MarkdownToken[],
tokensB: MarkdownToken[]
): AlignedDiffRow[] {
// Filter out whitespace-only 'space' tokens between blocks
const blocksA = tokensA.filter(t => t.type !== 'space');
const blocksB = tokensB.filter(t => t.type !== 'space');
const m = blocksA.length;
const n = blocksB.length;
// DP table to find LCS of exact matches
const dp: number[][] = Array.from({length: m + 1}, () =>
new Array(n + 1).fill(0)
);
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (blocksA[i].raw === blocksB[j].raw) {
dp[i + 1][j + 1] = dp[i][j] + 1;
} else {
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
}
// Backtrack to extract exact match pairs
const anchorPairs: {aIdx: number; bIdx: number}[] = [];
let i = m;
let j = n;
while (i > 0 && j > 0) {
if (blocksA[i - 1].raw === blocksB[j - 1].raw) {
anchorPairs.unshift({aIdx: i - 1, bIdx: j - 1});
i--;
j--;
} else if (dp[i][j - 1] >= dp[i - 1][j]) {
j--;
} else {
i--;
}
}
const rows: AlignedDiffRow[] = [];
let lastA = 0;
let lastB = 0;
function processInterval(endA: number, endB: number) {
const unalignedA = blocksA.slice(lastA, endA);
const unalignedB = blocksB.slice(lastB, endB);
let idxA = 0;
let idxB = 0;
// Greedily pair up adjacent tokens of compatible type as 'modified'
while (idxA < unalignedA.length && idxB < unalignedB.length) {
const tokA = unalignedA[idxA];
const tokB = unalignedB[idxB];
const canPair =
tokA.type === tokB.type ||
(tokA.type === 'paragraph' && tokB.type === 'paragraph') ||
(tokA.type === 'heading' && tokB.type === 'heading') ||
(tokA.type === 'code' && tokB.type === 'code');
if (canPair) {
let leftHtml: string;
let rightHtml: string;
if (tokA.type === 'code' && tokB.type === 'code') {
const diff = renderCodeBlockDiff(
tokA as Tokens.Code,
tokB as Tokens.Code
);
leftHtml = diff.leftHtml;
rightHtml = diff.rightHtml;
} else if (tokA.type === 'heading' && tokB.type === 'heading') {
const depth = (tokB as Tokens.Heading).depth;
const diff = renderInlineTextDiff(tokA.text, tokB.text, `h${depth}`);
leftHtml = diff.leftHtml;
rightHtml = diff.rightHtml;
} else if (tokA.type === 'paragraph' && tokB.type === 'paragraph') {
const diff = renderInlineTextDiff(tokA.text, tokB.text, 'p');
leftHtml = diff.leftHtml;
rightHtml = diff.rightHtml;
} else {
leftHtml = renderTokenToHtml(tokA);
rightHtml = renderTokenToHtml(tokB);
}
rows.push({
status: 'modified',
leftToken: tokA,
rightToken: tokB,
leftHtml,
rightHtml,
leftStartLine: tokA.startLine,
leftEndLine: tokA.endLine,
rightStartLine: tokB.startLine,
rightEndLine: tokB.endLine,
});
idxA++;
idxB++;
} else {
// Output deleted block on left
rows.push({
status: 'deleted',
leftToken: tokA,
leftHtml: renderTokenToHtml(tokA),
leftStartLine: tokA.startLine,
leftEndLine: tokA.endLine,
});
idxA++;
}
}
// Remaining unmatched in A
while (idxA < unalignedA.length) {
const tokA = unalignedA[idxA];
rows.push({
status: 'deleted',
leftToken: tokA,
leftHtml: renderTokenToHtml(tokA),
leftStartLine: tokA.startLine,
leftEndLine: tokA.endLine,
});
idxA++;
}
// Remaining unmatched in B
while (idxB < unalignedB.length) {
const tokB = unalignedB[idxB];
rows.push({
status: 'added',
rightToken: tokB,
rightHtml: renderTokenToHtml(tokB),
rightStartLine: tokB.startLine,
rightEndLine: tokB.endLine,
});
idxB++;
}
}
// Interleave intervals between anchor pairs
for (const anchor of anchorPairs) {
processInterval(anchor.aIdx, anchor.bIdx);
const tokA = blocksA[anchor.aIdx];
const tokB = blocksB[anchor.bIdx];
const htmlA = renderTokenToHtml(tokA);
const htmlB = renderTokenToHtml(tokB);
rows.push({
status: 'unchanged',
leftToken: tokA,
rightToken: tokB,
leftHtml: htmlA,
rightHtml: htmlB,
leftStartLine: tokA.startLine,
leftEndLine: tokA.endLine,
rightStartLine: tokB.startLine,
rightEndLine: tokB.endLine,
});
lastA = anchor.aIdx + 1;
lastB = anchor.bIdx + 1;
}
// Trailing interval
processInterval(m, n);
return rows;
}
/** Determine which side of the diff (LEFT or RIGHT) a comment thread belongs to. */
export function getThreadDiffSide(
thread: CommentThread,
patchRange?: PatchRange
): Side {
if (!patchRange) {
return thread.commentSide === CommentSide.PARENT ? Side.LEFT : Side.RIGHT;
}
const commentProps = {
patch_set: thread.patchNum,
side: thread.commentSide,
parent: thread.mergeParentNum,
};
if (isInBaseOfPatchRange(commentProps, patchRange)) {
return Side.LEFT;
}
return Side.RIGHT;
}
/**
* Assigns comment threads to their corresponding markdown diff rows based on line
* numbers and diff side. File-level comments (or threads without line numbers) are
* grouped separately.
*/
export function attachThreadsToRows(
rows: AlignedDiffRow[],
threads: CommentThread[] = [],
patchRange?: PatchRange
): {
rowsWithThreads: AlignedDiffRowWithThreads[];
fileLevelThreads: CommentThread[];
} {
const rowsWithThreads: AlignedDiffRowWithThreads[] = rows.map(r => {
return {
...r,
leftThreads: [],
rightThreads: [],
};
});
const fileLevelThreads: CommentThread[] = [];
if (rowsWithThreads.length === 0) {
return {
rowsWithThreads,
fileLevelThreads: [...threads],
};
}
for (const thread of threads) {
const line = thread.line;
if (line === undefined || line === 'FILE') {
fileLevelThreads.push(thread);
continue;
}
const lineNum = typeof line === 'number' ? line : Number(line);
if (isNaN(lineNum)) {
fileLevelThreads.push(thread);
continue;
}
const side = getThreadDiffSide(thread, patchRange);
if (side === Side.LEFT) {
const leftRows = rowsWithThreads.filter(
r => r.leftStartLine !== undefined
);
if (leftRows.length === 0) {
rowsWithThreads[0].leftThreads.push(thread);
continue;
}
const exactRow = leftRows.find(
r => lineNum >= r.leftStartLine! && lineNum <= r.leftEndLine!
);
if (exactRow) {
exactRow.leftThreads.push(thread);
continue;
}
if (lineNum < leftRows[0].leftStartLine!) {
leftRows[0].leftThreads.push(thread);
continue;
}
let targetRow = leftRows[leftRows.length - 1];
for (let i = 0; i < leftRows.length - 1; i++) {
if (
lineNum > leftRows[i].leftEndLine! &&
lineNum < leftRows[i + 1].leftStartLine!
) {
targetRow = leftRows[i];
break;
}
}
targetRow.leftThreads.push(thread);
} else {
const rightRows = rowsWithThreads.filter(
r => r.rightStartLine !== undefined
);
if (rightRows.length === 0) {
rowsWithThreads[0].rightThreads.push(thread);
continue;
}
const exactRow = rightRows.find(
r => lineNum >= r.rightStartLine! && lineNum <= r.rightEndLine!
);
if (exactRow) {
exactRow.rightThreads.push(thread);
continue;
}
if (lineNum < rightRows[0].rightStartLine!) {
rightRows[0].rightThreads.push(thread);
continue;
}
let targetRow = rightRows[rightRows.length - 1];
for (let i = 0; i < rightRows.length - 1; i++) {
if (
lineNum > rightRows[i].rightEndLine! &&
lineNum < rightRows[i + 1].rightStartLine!
) {
targetRow = rightRows[i];
break;
}
}
targetRow.rightThreads.push(thread);
}
}
return {rowsWithThreads, fileLevelThreads};
}