Blame · Line-by-line history
code-suggestions.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 7c0203e | 1 | /** |
| 2 | * Block J22 — Code review suggestion blocks. | |
| 3 | * | |
| 4 | * GitHub-style ```suggestion ... ``` fences in PR comments. The reviewer | |
| 5 | * proposes a replacement for the commented line(s); the reader can | |
| 6 | * one-click "Commit suggestion" to write it to the PR's head branch. | |
| 7 | * | |
| 8 | * This module is pure: | |
| 9 | * - `extractSuggestions(body)` parses a comment body into suggestion | |
| 10 | * blocks preserving their order + raw content + source offsets. | |
| 11 | * - `applySuggestionToContent({content, startLine, endLine, suggestion})` | |
| 12 | * returns the new file content with lines `startLine..endLine` | |
| 13 | * (1-indexed, inclusive) replaced by `suggestion`, preserving the | |
| 14 | * file's original line ending. | |
| 15 | * | |
| 16 | * We deliberately restrict ourselves to **single-suggestion commits**: | |
| 17 | * each POST applies one block, against the anchor line recorded on the | |
| 18 | * PR comment (a single line in our schema). Multi-line ranges — GitHub | |
| 19 | * supports `@@ ... @@`-style deltas — are a later extension. | |
| 20 | */ | |
| 21 | ||
| 22 | export interface SuggestionBlock { | |
| 23 | /** The suggestion's replacement text, verbatim between the fences. */ | |
| 24 | content: string; | |
| 25 | /** Character offset of the opening fence in the comment body. */ | |
| 26 | startOffset: number; | |
| 27 | /** Character offset immediately after the closing fence. */ | |
| 28 | endOffset: number; | |
| 29 | /** Position in the body (0-indexed). */ | |
| 30 | index: number; | |
| 31 | } | |
| 32 | ||
| 33 | /** | |
| 34 | * Detect the dominant line ending in the given content. Heuristic: | |
| 35 | * if any CRLF is present the whole file is treated as CRLF; otherwise LF. | |
| 36 | */ | |
| 37 | export function detectLineEnding(content: string): "\r\n" | "\n" { | |
| 38 | return content.includes("\r\n") ? "\r\n" : "\n"; | |
| 39 | } | |
| 40 | ||
| 41 | /** Split on `\r\n` or `\n` without consuming the last empty element. */ | |
| 42 | export function splitLines(content: string): string[] { | |
| 43 | return content.split(/\r?\n/); | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * Parse a comment body into an array of suggestion blocks. Matches | |
| 48 | * fenced blocks where the info-string begins with `suggestion`. Handles: | |
| 49 | * - Indented fences (up to 3 leading spaces) — CommonMark parity | |
| 50 | * - Blocks using 3+ backticks (closing fence must be ≥ opener count) | |
| 51 | * - The content is returned verbatim, with the trailing newline before | |
| 52 | * the closing fence stripped (GitHub's renderer does this). | |
| 53 | * Blocks opened but never closed are skipped. | |
| 54 | */ | |
| 55 | export function extractSuggestions(body: string): SuggestionBlock[] { | |
| 56 | if (!body || typeof body !== "string") return []; | |
| 57 | const out: SuggestionBlock[] = []; | |
| 58 | const fenceRe = /^[ ]{0,3}(`{3,})[ \t]*suggestion[^\n]*\n/gm; | |
| 59 | let m: RegExpExecArray | null; | |
| 60 | let idx = 0; | |
| 61 | while ((m = fenceRe.exec(body)) !== null) { | |
| 62 | const fence = m[1]; | |
| 63 | const openStart = m.index; | |
| 64 | const contentStart = openStart + m[0].length; | |
| 65 | // Find the matching closing fence: a line starting with ≥fence.length | |
| 66 | // backticks, up to 3 spaces of indent, at the start of a line. | |
| 67 | const closeRe = new RegExp( | |
| 68 | `(\\r?\\n)?[ ]{0,3}\`{${fence.length},}[ \\t]*(?:\\r?\\n|$)`, | |
| 69 | "g" | |
| 70 | ); | |
| 71 | closeRe.lastIndex = contentStart; | |
| 72 | const close = closeRe.exec(body); | |
| 73 | if (!close) break; // unterminated — ignore | |
| 74 | // Ensure the close is at the start of a line (either body[close.index] | |
| 75 | // is a newline we captured, or close.index === contentStart). | |
| 76 | const rawContent = body.slice(contentStart, close.index); | |
| 77 | out.push({ | |
| 78 | content: rawContent, | |
| 79 | startOffset: openStart, | |
| 80 | endOffset: close.index + close[0].length, | |
| 81 | index: idx++, | |
| 82 | }); | |
| 83 | fenceRe.lastIndex = close.index + close[0].length; | |
| 84 | } | |
| 85 | return out; | |
| 86 | } | |
| 87 | ||
| 88 | export interface ApplyOpts { | |
| 89 | /** Original file content. */ | |
| 90 | content: string; | |
| 91 | /** 1-indexed, inclusive. */ | |
| 92 | startLine: number; | |
| 93 | /** 1-indexed, inclusive. */ | |
| 94 | endLine: number; | |
| 95 | /** Replacement text. Trailing newline is stripped if present. */ | |
| 96 | suggestion: string; | |
| 97 | } | |
| 98 | ||
| 99 | export interface ApplyResult { | |
| 100 | ok: boolean; | |
| 101 | /** New content (only set when ok). */ | |
| 102 | content?: string; | |
| 103 | /** Reason for failure (only set when !ok). */ | |
| 104 | reason?: | |
| 105 | | "bad_range" | |
| 106 | | "line_out_of_bounds" | |
| 107 | | "empty_content" | |
| 108 | | "no_change"; | |
| 109 | } | |
| 110 | ||
| 111 | /** | |
| 112 | * Replace file lines `startLine..endLine` with the suggestion. | |
| 113 | * Preserves the dominant line ending in the original file. Returns the | |
| 114 | * new file content (ending with the same terminal newline presence as | |
| 115 | * the original). | |
| 116 | */ | |
| 117 | export function applySuggestionToContent(opts: ApplyOpts): ApplyResult { | |
| 118 | const { content, startLine, endLine, suggestion } = opts; | |
| 119 | if (typeof content !== "string") return { ok: false, reason: "empty_content" }; | |
| 120 | if ( | |
| 121 | !Number.isInteger(startLine) || | |
| 122 | !Number.isInteger(endLine) || | |
| 123 | startLine < 1 || | |
| 124 | endLine < startLine | |
| 125 | ) { | |
| 126 | return { ok: false, reason: "bad_range" }; | |
| 127 | } | |
| 128 | const eol = detectLineEnding(content); | |
| 129 | const hadTrailingNewline = /\r?\n$/.test(content); | |
| 130 | const lines = splitLines(content); | |
| 131 | // splitLines yields a trailing "" element when the input ends in \n — | |
| 132 | // drop it to get true line count. | |
| 133 | const trimmedLines = | |
| 134 | hadTrailingNewline && lines[lines.length - 1] === "" | |
| 135 | ? lines.slice(0, -1) | |
| 136 | : lines; | |
| 137 | if (endLine > trimmedLines.length) { | |
| 138 | return { ok: false, reason: "line_out_of_bounds" }; | |
| 139 | } | |
| 140 | // Normalise suggestion to LF then split — we'll rejoin with eol below. | |
| 141 | const replacement = suggestion.replace(/\r\n/g, "\n").replace(/\n$/, ""); | |
| 142 | const replacementLines = replacement.split("\n"); | |
| 143 | const before = trimmedLines.slice(0, startLine - 1); | |
| 144 | const after = trimmedLines.slice(endLine); | |
| 145 | const next = [...before, ...replacementLines, ...after]; | |
| 146 | let result = next.join(eol); | |
| 147 | if (hadTrailingNewline) result += eol; | |
| 148 | if (result === content) return { ok: false, reason: "no_change" }; | |
| 149 | return { ok: true, content: result }; | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Convenience: apply the Nth suggestion from a comment body to a file. | |
| 154 | * Returns `{ok:false, reason:'not_found'}` if the index is out of range. | |
| 155 | */ | |
| 156 | export function applyNthSuggestion( | |
| 157 | body: string, | |
| 158 | n: number, | |
| 159 | opts: Omit<ApplyOpts, "suggestion"> | |
| 160 | ): ApplyResult | { ok: false; reason: "not_found" } { | |
| 161 | const blocks = extractSuggestions(body); | |
| 162 | if (n < 0 || n >= blocks.length) return { ok: false, reason: "not_found" }; | |
| 163 | return applySuggestionToContent({ ...opts, suggestion: blocks[n].content }); | |
| 164 | } | |
| 165 | ||
| 166 | export const __internal = { | |
| 167 | detectLineEnding, | |
| 168 | splitLines, | |
| 169 | extractSuggestions, | |
| 170 | applySuggestionToContent, | |
| 171 | applyNthSuggestion, | |
| 172 | }; |