1import { useSyncExternalStore } from "react";
2import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
3
4import type { ReviewRenderableLineRow } from "./reviewModel";
5
6export interface ReviewCommentTarget {
7 readonly sectionId: string;
8 readonly sectionTitle: string;
9 readonly filePath: string;
10 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
11 readonly startIndex: number;
12 readonly endIndex: number;
13}
14
15export interface ReviewInlineComment {
16 readonly id: string;
17 readonly sectionId: string;
18 readonly sectionTitle: string;
19 readonly filePath: string;
20 readonly startIndex: number;
21 readonly endIndex: number;
22 readonly rangeLabel: string;
23 readonly text: string;
24 readonly diff: string;
25 readonly fenceLanguage?: string;
26}
27
28export type ReviewCommentMessageSegment =
29 | {
30 readonly kind: "text";
31 readonly id: string;
32 readonly text: string;
33 }
34 | {
35 readonly kind: "review-comment";
36 readonly comment: ReviewInlineComment;
37 };
38
39let currentTarget: ReviewCommentTarget | null = null;
40const listeners = new Set<() => void>();
41const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g;
42const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
43const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g;
44
45function emitChange() {
46 listeners.forEach((listener) => listener());
47}
48
49export function subscribeReviewCommentTarget(listener: () => void): () => void {
50 listeners.add(listener);
51 return () => {
52 listeners.delete(listener);
53 };
54}
55
56export function getReviewCommentTarget(): ReviewCommentTarget | null {
57 return currentTarget;
58}
59
60export function setReviewCommentTarget(target: ReviewCommentTarget | null) {
61 currentTarget = target;
62 emitChange();
63}
64
65export function clearReviewCommentTarget() {
66 currentTarget = null;
67 emitChange();
68}
69
70export function useReviewCommentTarget(): ReviewCommentTarget | null {
71 return useSyncExternalStore(
72 subscribeReviewCommentTarget,
73 getReviewCommentTarget,
74 getReviewCommentTarget,
75 );
76}
77
78export function getSelectedReviewCommentLines(
79 target: ReviewCommentTarget,
80): ReadonlyArray<ReviewRenderableLineRow> {
81 return target.lines.slice(target.startIndex, target.endIndex + 1);
82}
83
84export function getReviewUnifiedLineNumber(line: ReviewRenderableLineRow): number | null {
85 return line.newLineNumber ?? line.oldLineNumber;
86}
87
88export function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]): string {
89 if (change === "add") return "+";
90 if (change === "delete") return "-";
91 return " ";
92}
93
94export function buildReviewCommentTarget(
95 target: Pick<ReviewCommentTarget, "sectionId" | "sectionTitle" | "filePath" | "lines">,
96 anchorIndex: number,
97 lineIndex: number,
98): ReviewCommentTarget {
99 return {
100 sectionId: target.sectionId,
101 sectionTitle: target.sectionTitle,
102 filePath: target.filePath,
103 lines: target.lines,
104 startIndex: Math.min(anchorIndex, lineIndex),
105 endIndex: Math.max(anchorIndex, lineIndex),
106 };
107}
108
109export function formatReviewSelectedRangeLabel(target: ReviewCommentTarget): string {
110 const lines = getSelectedReviewCommentLines(target);
111 const firstLine = lines[0]!;
112 const lastLine = lines[lines.length - 1]!;
113 const firstNumber = getReviewUnifiedLineNumber(firstLine);
114 const lastNumber = getReviewUnifiedLineNumber(lastLine);
115
116 if (firstNumber === null || lastNumber === null) {
117 return lines.length === 1 ? "line" : `${lines.length} lines`;
118 }
119
120 const firstMarker = getReviewChangeMarker(firstLine.change).trim();
121 const consistentMarker =
122 lines.every((line) => line.change === firstLine.change) && firstMarker.length > 0
123 ? getReviewChangeMarker(firstLine.change)
124 : "";
125
126 if (firstNumber === lastNumber) {
127 return `${consistentMarker}${firstNumber}`;
128 }
129
130 return `${consistentMarker}${firstNumber} to ${consistentMarker}${lastNumber}`;
131}
132
133function getDiffHunkRange(
134 selectedLines: ReadonlyArray<ReviewRenderableLineRow>,
135 key: "oldLineNumber" | "newLineNumber",
136): {
137 readonly start: number;
138 readonly count: number;
139} {
140 const numberedLines = selectedLines.filter((line) => line[key] !== null);
141 if (numberedLines.length === 0) {
142 return { start: 0, count: 0 };
143 }
144
145 return {
146 start: numberedLines[0]![key] ?? 0,
147 count: numberedLines.length,
148 };
149}
150
151function formatReviewSelectedDiff(target: ReviewCommentTarget): string {
152 const selectedLines = getSelectedReviewCommentLines(target);
153 const oldRange = getDiffHunkRange(selectedLines, "oldLineNumber");
154 const newRange = getDiffHunkRange(selectedLines, "newLineNumber");
155 const diffBody = selectedLines
156 .map((line) => `${getReviewChangeMarker(line.change)}${line.content}`)
157 .join("\n");
158
159 return [
160 `@@ -${oldRange.start},${oldRange.count} +${newRange.start},${newRange.count} @@`,
161 diffBody.length > 0 ? diffBody : " ",
162 ].join("\n");
163}
164
165function escapeReviewCommentAttribute(value: string): string {
166 return value
167 .replace(/&/g, "&amp;")
168 .replace(/"/g, "&quot;")
169 .replace(/</g, "&lt;")
170 .replace(/>/g, "&gt;");
171}
172
173function unescapeReviewCommentAttribute(value: string): string {
174 return value
175 .replace(/&lt;/g, "<")
176 .replace(/&gt;/g, ">")
177 .replace(/&quot;/g, '"')
178 .replace(/&amp;/g, "&");
179}
180
181function readReviewCommentAttributes(rawAttributes: string): Record<string, string> {
182 const attributes: Record<string, string> = {};
183 for (const match of rawAttributes.matchAll(REVIEW_COMMENT_ATTRIBUTE_PATTERN)) {
184 attributes[match[1]!] = unescapeReviewCommentAttribute(match[2] ?? "");
185 }
186 return attributes;
187}
188
189function readNonNegativeInteger(value: string | undefined): number | null {
190 if (value === undefined || !/^\d+$/.test(value)) {
191 return null;
192 }
193 return Number(value);
194}
195
196function extractReviewCommentBody(rawBody: string): {
197 text: string;
198 language: string;
199 contents: string;
200} {
201 const matches = Array.from(rawBody.matchAll(REVIEW_COMMENT_FENCE_PATTERN));
202 const match = matches.at(-1);
203 const fenceIndex = match?.index;
204 return {
205 text: rawBody.slice(0, fenceIndex ?? rawBody.length).trim(),
206 language: match?.[2]?.trim() || "diff",
207 contents: match?.[3] ?? "",
208 };
209}
210
211function parseReviewInlineComment(
212 rawAttributes: string,
213 rawBody: string,
214 index: number,
215): ReviewInlineComment | null {
216 const attributes = readReviewCommentAttributes(rawAttributes);
217 const startIndex = readNonNegativeInteger(attributes.startIndex);
218 const endIndex = readNonNegativeInteger(attributes.endIndex);
219 const filePath = attributes.filePath?.trim();
220 const sectionId = attributes.sectionId?.trim();
221 if (!filePath || !sectionId || startIndex === null || endIndex === null) {
222 return null;
223 }
224 const body = extractReviewCommentBody(rawBody);
225
226 return {
227 id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`,
228 sectionId,
229 sectionTitle: attributes.sectionTitle?.trim() || "Review",
230 filePath,
231 startIndex: Math.min(startIndex, endIndex),
232 endIndex: Math.max(startIndex, endIndex),
233 rangeLabel: attributes.rangeLabel?.trim() || "line",
234 text: body.text,
235 diff: body.contents,
236 fenceLanguage: body.language,
237 };
238}
239
240export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string {
241 const rangeLabel = formatReviewSelectedRangeLabel(target);
242 const diff = formatReviewSelectedDiff(target);
243 const longestBacktickRun = Math.max(
244 0,
245 ...Array.from(diff.matchAll(/`+/g), (match) => match[0].length),
246 );
247 const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
248 return [
249 [
250 "<review_comment",
251 ` sectionId="${escapeReviewCommentAttribute(target.sectionId)}"`,
252 ` sectionTitle="${escapeReviewCommentAttribute(target.sectionTitle)}"`,
253 ` filePath="${escapeReviewCommentAttribute(target.filePath)}"`,
254 ` startIndex="${target.startIndex}"`,
255 ` endIndex="${target.endIndex}"`,
256 ` rangeLabel="${escapeReviewCommentAttribute(rangeLabel)}"`,
257 ">",
258 ].join(""),
259 comment.trim(),
260 `${fence}diff`,
261 diff,
262 fence,
263 "</review_comment>",
264 ].join("\n");
265}
266
267export function countReviewCommentContexts(value: string): number {
268 return Array.from(value.matchAll(/<review_comment\b/g)).length;
269}
270
271export function parseReviewInlineComments(value: string): ReadonlyArray<ReviewInlineComment> {
272 const comments: ReviewInlineComment[] = [];
273 // Match on masked delimiters, as `parseReviewCommentMessageSegments` does: a chip label may
274 // contain `</review_comment>`, which would otherwise end the block early and truncate it.
275 const masked = replaceComposerContextReferences(value, (reference) =>
276 " ".repeat(reference.source.length),
277 );
278 for (const [index, match] of Array.from(
279 masked.matchAll(REVIEW_COMMENT_BLOCK_PATTERN),
280 ).entries()) {
281 const matchIndex = match.index;
282 const raw = value.slice(matchIndex, matchIndex + match[0].length);
283 const attributeStart = "<review_comment".length;
284 const attributeEnd = attributeStart + (match[1]?.length ?? 0);
285 const comment = parseReviewInlineComment(
286 raw.slice(attributeStart, attributeEnd),
287 raw.slice(attributeEnd + 1, -"</review_comment>".length),
288 index,
289 );
290 if (!comment) {
291 continue;
292 }
293
294 comments.push(comment);
295 }
296 return comments;
297}
298
299export function parseReviewCommentMessageSegments(
300 value: string,
301): ReadonlyArray<ReviewCommentMessageSegment> {
302 const segments: ReviewCommentMessageSegment[] = [];
303 let cursor = 0;
304 let parsedCommentIndex = 0;
305 // Labels are opaque text, even when they contain legacy review markup. Keep offsets intact.
306 const masked = replaceComposerContextReferences(value, (reference) =>
307 " ".repeat(reference.source.length),
308 );
309 for (const match of masked.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) {
310 const matchIndex = match.index;
311 const beforeText = value.slice(cursor, matchIndex);
312 if (beforeText.length > 0) {
313 segments.push({
314 kind: "text",
315 id: `review-comment-text:${cursor}`,
316 text: beforeText,
317 });
318 }
319
320 // Use the masked delimiters but read the original payload. Re-parsing raw text could
321 // mistake a closing tag inside a chip label for the end of the review.
322 const raw = value.slice(matchIndex, matchIndex + match[0].length);
323 const attributeStart = "<review_comment".length;
324 const attributeEnd = attributeStart + (match[1]?.length ?? 0);
325 const comment = parseReviewInlineComment(
326 raw.slice(attributeStart, attributeEnd),
327 raw.slice(attributeEnd + 1, -"</review_comment>".length),
328 parsedCommentIndex,
329 );
330 if (comment) {
331 segments.push({ kind: "review-comment", comment });
332 parsedCommentIndex += 1;
333 } else {
334 segments.push({
335 kind: "text",
336 id: `review-comment-invalid:${matchIndex}`,
337 text: value.slice(matchIndex, matchIndex + match[0].length),
338 });
339 }
340
341 cursor = matchIndex + match[0].length;
342 }
343
344 const rest = value.slice(cursor);
345 if (rest.length > 0) {
346 segments.push({
347 kind: "text",
348 id: `review-comment-text:${cursor}`,
349 text: rest,
350 });
351 }
352
353 return segments;
354}