1import { diffWordsWithSpace } from "diff";
2
3import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
4
5interface ReviewDiffOperation {
6 readonly value: string;
7 readonly added?: true;
8 readonly removed?: true;
9}
10
11interface ReviewDiffHighlightRange {
12 readonly start: number;
13 readonly end: number;
14}
15
16const REVIEW_MAX_WORD_ALT_LINE_LENGTH = 1_000;
17
18function cleanLineEnding(value: string): string {
19 return value.endsWith("\n") ? value.slice(0, -1) : value;
20}
21
22function pushOrJoinSpan(input: {
23 readonly operation: ReviewDiffOperation;
24 readonly spans: Array<[0 | 1, string]>;
25 readonly enableJoin: boolean;
26 readonly isNeutral?: boolean;
27 readonly isLastOperation?: boolean;
28}): void {
29 const { operation, spans, enableJoin, isNeutral = false, isLastOperation = false } = input;
30 const lastSpan = spans.at(-1);
31
32 if (!lastSpan || isLastOperation || !enableJoin) {
33 spans.push([isNeutral ? 0 : 1, operation.value]);
34 return;
35 }
36
37 const lastSpanIsNeutral = lastSpan[0] === 0;
38 if (
39 isNeutral === lastSpanIsNeutral ||
40 (isNeutral && operation.value.length === 1 && !lastSpanIsNeutral)
41 ) {
42 lastSpan[1] += operation.value;
43 return;
44 }
45
46 spans.push([isNeutral ? 0 : 1, operation.value]);
47}
48
49function spansToRanges(
50 spans: ReadonlyArray<readonly [0 | 1, string]>,
51): ReadonlyArray<ReviewDiffHighlightRange> {
52 const ranges: ReviewDiffHighlightRange[] = [];
53 let offset = 0;
54
55 spans.forEach(([kind, value]) => {
56 const nextOffset = offset + value.length;
57 if (kind === 1 && value.length > 0) {
58 ranges.push({ start: offset, end: nextOffset });
59 }
60 offset = nextOffset;
61 });
62
63 return ranges;
64}
65
66function mergeNearbyRanges(
67 ranges: ReadonlyArray<ReviewDiffHighlightRange>,
68): ReadonlyArray<ReviewDiffHighlightRange> {
69 if (ranges.length <= 1) {
70 return ranges;
71 }
72
73 const merged: ReviewDiffHighlightRange[] = [];
74
75 ranges.forEach((range) => {
76 const previous = merged.at(-1);
77 if (previous && range.start - previous.end <= 1) {
78 merged[merged.length - 1] = { start: previous.start, end: range.end };
79 return;
80 }
81 merged.push({ ...range });
82 });
83
84 return merged;
85}
86
87function appendTokenSegment(
88 target: ReviewHighlightedToken[],
89 source: ReviewHighlightedToken,
90 content: string,
91 diffHighlight: boolean,
92): void {
93 if (content.length === 0) {
94 return;
95 }
96
97 const previous = target.at(-1);
98 if (
99 previous &&
100 previous.color === source.color &&
101 previous.fontStyle === source.fontStyle &&
102 previous.diffHighlight === diffHighlight
103 ) {
104 previous.content += content;
105 return;
106 }
107
108 target.push({
109 content,
110 color: source.color,
111 fontStyle: source.fontStyle,
112 diffHighlight,
113 });
114}
115
116export function computeWordAltDiffRanges(input: {
117 readonly deletionLine: string;
118 readonly additionLine: string;
119}): {
120 readonly deletion: ReadonlyArray<ReviewDiffHighlightRange>;
121 readonly addition: ReadonlyArray<ReviewDiffHighlightRange>;
122} {
123 const deletionLine = cleanLineEnding(input.deletionLine);
124 const additionLine = cleanLineEnding(input.additionLine);
125
126 if (deletionLine.length === 0 && additionLine.length === 0) {
127 return { deletion: [], addition: [] };
128 }
129
130 if (
131 deletionLine.length > REVIEW_MAX_WORD_ALT_LINE_LENGTH ||
132 additionLine.length > REVIEW_MAX_WORD_ALT_LINE_LENGTH
133 ) {
134 return { deletion: [], addition: [] };
135 }
136
137 const operations = diffWordsWithSpace(
138 deletionLine,
139 additionLine,
140 ) as ReadonlyArray<ReviewDiffOperation>;
141 const deletionSpans: Array<[0 | 1, string]> = [];
142 const additionSpans: Array<[0 | 1, string]> = [];
143 const lastOperation = operations.at(-1);
144
145 operations.forEach((operation) => {
146 const isLastOperation = operation === lastOperation;
147 if (!operation.added && !operation.removed) {
148 pushOrJoinSpan({
149 operation,
150 spans: deletionSpans,
151 enableJoin: true,
152 isNeutral: true,
153 isLastOperation,
154 });
155 pushOrJoinSpan({
156 operation,
157 spans: additionSpans,
158 enableJoin: true,
159 isNeutral: true,
160 isLastOperation,
161 });
162 return;
163 }
164
165 if (operation.removed) {
166 pushOrJoinSpan({
167 operation,
168 spans: deletionSpans,
169 enableJoin: true,
170 isLastOperation,
171 });
172 return;
173 }
174
175 pushOrJoinSpan({
176 operation,
177 spans: additionSpans,
178 enableJoin: true,
179 isLastOperation,
180 });
181 });
182
183 return {
184 deletion: mergeNearbyRanges(spansToRanges(deletionSpans)),
185 addition: mergeNearbyRanges(spansToRanges(additionSpans)),
186 };
187}
188
189export function applyDiffRangesToTokens(
190 tokens: ReadonlyArray<ReviewHighlightedToken>,
191 ranges: ReadonlyArray<ReviewDiffHighlightRange>,
192): ReadonlyArray<ReviewHighlightedToken> {
193 if (tokens.length === 0 || ranges.length === 0) {
194 return tokens;
195 }
196
197 const nextTokens: ReviewHighlightedToken[] = [];
198 let tokenOffset = 0;
199 let rangeIndex = 0;
200
201 tokens.forEach((token) => {
202 const tokenStart = tokenOffset;
203 const tokenEnd = tokenStart + token.content.length;
204 tokenOffset = tokenEnd;
205
206 if (token.content.length === 0) {
207 nextTokens.push(token);
208 return;
209 }
210
211 while (rangeIndex < ranges.length && ranges[rangeIndex]!.end <= tokenStart) {
212 rangeIndex += 1;
213 }
214
215 if ((ranges[rangeIndex]?.start ?? Number.POSITIVE_INFINITY) >= tokenEnd) {
216 nextTokens.push(token.diffHighlight ? { ...token, diffHighlight: false } : token);
217 return;
218 }
219
220 let cursor = tokenStart;
221 let localRangeIndex = rangeIndex;
222
223 while ((ranges[localRangeIndex]?.start ?? Number.POSITIVE_INFINITY) < tokenEnd) {
224 const range = ranges[localRangeIndex];
225 if (!range) {
226 break;
227 }
228
229 if (range.start > cursor) {
230 appendTokenSegment(
231 nextTokens,
232 token,
233 token.content.slice(cursor - tokenStart, range.start - tokenStart),
234 false,
235 );
236 }
237
238 const highlightedStart = Math.max(cursor, range.start);
239 const highlightedEnd = Math.min(tokenEnd, range.end);
240
241 if (highlightedEnd > highlightedStart) {
242 appendTokenSegment(
243 nextTokens,
244 token,
245 token.content.slice(highlightedStart - tokenStart, highlightedEnd - tokenStart),
246 true,
247 );
248 }
249
250 cursor = highlightedEnd;
251 if (range.end <= tokenEnd) {
252 localRangeIndex += 1;
253 } else {
254 break;
255 }
256 }
257
258 if (cursor < tokenEnd) {
259 appendTokenSegment(nextTokens, token, token.content.slice(cursor - tokenStart), false);
260 }
261
262 rangeIndex = localRangeIndex;
263 });
264
265 return nextTokens;
266}