| 1 | import { useSyncExternalStore } from "react"; |
| 2 | import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; |
| 3 | |
| 4 | import type { ReviewRenderableLineRow } from "./reviewModel"; |
| 5 | |
| 6 | export 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 | |
| 15 | export 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 | |
| 28 | export 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 | |
| 39 | let currentTarget: ReviewCommentTarget | null = null; |
| 40 | const listeners = new Set<() => void>(); |
| 41 | const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g; |
| 42 | const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g; |
| 43 | const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g; |
| 44 | |
| 45 | function emitChange() { |
| 46 | listeners.forEach((listener) => listener()); |
| 47 | } |
| 48 | |
| 49 | export function subscribeReviewCommentTarget(listener: () => void): () => void { |
| 50 | listeners.add(listener); |
| 51 | return () => { |
| 52 | listeners.delete(listener); |
| 53 | }; |
| 54 | } |
| 55 | |
| 56 | export function getReviewCommentTarget(): ReviewCommentTarget | null { |
| 57 | return currentTarget; |
| 58 | } |
| 59 | |
| 60 | export function setReviewCommentTarget(target: ReviewCommentTarget | null) { |
| 61 | currentTarget = target; |
| 62 | emitChange(); |
| 63 | } |
| 64 | |
| 65 | export function clearReviewCommentTarget() { |
| 66 | currentTarget = null; |
| 67 | emitChange(); |
| 68 | } |
| 69 | |
| 70 | export function useReviewCommentTarget(): ReviewCommentTarget | null { |
| 71 | return useSyncExternalStore( |
| 72 | subscribeReviewCommentTarget, |
| 73 | getReviewCommentTarget, |
| 74 | getReviewCommentTarget, |
| 75 | ); |
| 76 | } |
| 77 | |
| 78 | export function getSelectedReviewCommentLines( |
| 79 | target: ReviewCommentTarget, |
| 80 | ): ReadonlyArray<ReviewRenderableLineRow> { |
| 81 | return target.lines.slice(target.startIndex, target.endIndex + 1); |
| 82 | } |
| 83 | |
| 84 | export function getReviewUnifiedLineNumber(line: ReviewRenderableLineRow): number | null { |
| 85 | return line.newLineNumber ?? line.oldLineNumber; |
| 86 | } |
| 87 | |
| 88 | export function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]): string { |
| 89 | if (change === "add") return "+"; |
| 90 | if (change === "delete") return "-"; |
| 91 | return " "; |
| 92 | } |
| 93 | |
| 94 | export 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 | |
| 109 | export 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 | |
| 133 | function 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 | |
| 151 | function 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 | |
| 165 | function 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 | |
| 173 | function 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 | |
| 181 | function 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 | |
| 189 | function readNonNegativeInteger(value: string | undefined): number | null { |
| 190 | if (value === undefined || !/^\d+$/.test(value)) { |
| 191 | return null; |
| 192 | } |
| 193 | return Number(value); |
| 194 | } |
| 195 | |
| 196 | function 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 | |
| 211 | function 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 | |
| 240 | export 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 | |
| 267 | export function countReviewCommentContexts(value: string): number { |
| 268 | return Array.from(value.matchAll(/<review_comment\b/g)).length; |
| 269 | } |
| 270 | |
| 271 | export 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 | |
| 299 | export 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 | } |