pi-supernova 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,11 @@ import { isString } from "../shared/decode.js";
3
3
 
4
4
  function scanPython(lines) {
5
5
  const items = [];
6
+
6
7
  for (let i = 0; i < lines.length; i++) {
7
8
  const line = lines[i];
8
9
  const match = /^([ \t]*)(def|class|async def)\s+([a-zA-Z0-9_]+)(\(.*?\))?:?/.exec(line);
10
+
9
11
  if (!match) continue;
10
12
  items.push({
11
13
  kind: match[2].includes("def") ? "function" : "class",
@@ -15,14 +17,17 @@ function scanPython(lines) {
15
17
  depth: Math.floor(match[1].length / 4),
16
18
  });
17
19
  }
20
+
18
21
  return items;
19
22
  }
20
23
 
21
24
  function scanRust(lines) {
22
25
  const items = [];
26
+
23
27
  for (let i = 0; i < lines.length; i++) {
24
28
  const line = lines[i].trim();
25
29
  const match = /^(pub\s+)?(async\s+)?(fn|struct|enum|trait|impl|type|const)\s+([a-zA-Z0-9_]+)(<.*?>)?(\(.*?\))?/.exec(line);
30
+
26
31
  if (!match) continue;
27
32
  items.push({
28
33
  kind: match[3],
@@ -31,14 +36,17 @@ function scanRust(lines) {
31
36
  line: i + 1,
32
37
  });
33
38
  }
39
+
34
40
  return items;
35
41
  }
36
42
 
37
43
  function scanGo(lines) {
38
44
  const items = [];
45
+
39
46
  for (let i = 0; i < lines.length; i++) {
40
47
  const line = lines[i].trim();
41
48
  const funcMatch = /^func\s+(\(.*?\)\s+)?([a-zA-Z0-9_]+)(\(.*?\))/.exec(line);
49
+
42
50
  if (funcMatch) {
43
51
  items.push({
44
52
  kind: "function",
@@ -48,7 +56,9 @@ function scanGo(lines) {
48
56
  });
49
57
  continue;
50
58
  }
59
+
51
60
  const typeMatch = /^type\s+([a-zA-Z0-9_]+)\s+(struct|interface)/.exec(line);
61
+
52
62
  if (typeMatch) {
53
63
  items.push({
54
64
  kind: typeMatch[2],
@@ -58,6 +68,7 @@ function scanGo(lines) {
58
68
  });
59
69
  }
60
70
  }
71
+
61
72
  return items;
62
73
  }
63
74
 
@@ -66,37 +77,52 @@ const JS_DECL_PATTERNS = [
66
77
  [/^(?:async\s+)?(function\*?|class)\s+([a-zA-Z0-9_$]+)/, false],
67
78
  [/^(interface|type)\s+([a-zA-Z0-9_$]+)/, false],
68
79
  ];
80
+
69
81
  // Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
70
82
  const JS_TOP_LEVEL_BINDING = /^(const|let|var)\s+([a-zA-Z0-9_$]+)\s*=/;
71
- // Indented methods (object-literal adapters, class members) that open a block on the same line.
72
- const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{$/;
73
- const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{$/;
83
+
84
+ // Indented methods (object-literal adapters, class members), including one-liners.
85
+ const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{/;
86
+
87
+ const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{/;
88
+
74
89
  const NOT_METHOD_NAMES = new Set(["if", "for", "while", "switch", "catch", "function", "return", "else", "do", "try", "with", "await", "typeof", "new", "constructor"]);
75
90
 
76
91
  function methodItem(line, lineNumber, depth) {
77
92
  const match = JS_METHOD.exec(line) || JS_ARROW_PROPERTY.exec(line);
93
+
78
94
  if (!match || NOT_METHOD_NAMES.has(match[1])) return null;
79
- return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{$/, ""), line: lineNumber, depth };
95
+
96
+ return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{.*$/, "").trim(), line: lineNumber, depth };
80
97
  }
81
98
 
82
99
  function declarationItem(line, rawLine, lineNumber) {
100
+ const indent = rawLine.length - rawLine.trimStart().length;
101
+ const depth = Math.floor(indent / 2);
83
102
  const patterns = /^\S/.test(rawLine) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
103
+
84
104
  for (const [pattern, isExport] of patterns) {
85
105
  const match = pattern.exec(line);
86
- if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth: 0 };
106
+
107
+ if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth };
87
108
  }
109
+
88
110
  return null;
89
111
  }
90
112
 
91
113
  function scanJavaScript(lines) {
92
114
  const items = [];
115
+
93
116
  for (let i = 0; i < lines.length; i++) {
94
117
  const line = lines[i].trim();
118
+
95
119
  if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
96
120
  const indent = lines[i].length - lines[i].trimStart().length;
97
- const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, 1) : null);
121
+ const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, Math.max(1, Math.floor(indent / 2))) : null);
122
+
98
123
  if (item) items.push(item);
99
124
  }
125
+
100
126
  return items;
101
127
  }
102
128
 
@@ -118,5 +144,6 @@ export function extractStructuralSurface(code, extension = "js") {
118
144
  const ext = extension.replace(/^\./, "").toLowerCase();
119
145
  const scanner = SCANNERS[ext] || SCANNERS.js;
120
146
  const items = scanner(lines);
147
+
121
148
  return { items, lineCount: lines.length };
122
149
  }
package/src/fs/check.js CHANGED
@@ -3,7 +3,9 @@
3
3
  // on, so a broken edit is known now instead of after a test run. JSON is checked exactly.
4
4
 
5
5
  const OPEN = { "{": "}", "[": "]", "(": ")" };
6
+
6
7
  const CLOSE = new Set(["}", "]", ")"]);
8
+
7
9
  const REGEX_PRECEDERS = new Set(["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "<", ">", "~", "^", "return", "typeof", "case", "do", "else", "in", "of"]);
8
10
 
9
11
  function skipString(text, i, quote) {
@@ -12,9 +14,12 @@ function skipString(text, i, quote) {
12
14
  j++;
13
15
  continue;
14
16
  }
17
+
15
18
  if (text[j] === quote) return j + 1;
19
+
16
20
  if (quote !== "`" && text[j] === "\n") return -1;
17
21
  }
22
+
18
23
  return -1;
19
24
  }
20
25
 
@@ -24,13 +29,17 @@ function skipTemplate(text, i, stack) {
24
29
  j++;
25
30
  continue;
26
31
  }
32
+
27
33
  if (text[j] === "`") return j + 1;
34
+
28
35
  if (text[j] === "$" && text[j + 1] === "{") {
29
36
  const end = balancedEnd(text, j + 1, stack);
37
+
30
38
  if (end < 0) return -1;
31
39
  j = end - 1; // loop increment lands on the char after "}"
32
40
  }
33
41
  }
42
+
34
43
  return -1;
35
44
  }
36
45
 
@@ -38,67 +47,90 @@ function skipTemplate(text, i, stack) {
38
47
  function balancedEnd(text, i, stack) {
39
48
  const depth = stack.length;
40
49
  const r = scan(text, i, stack, depth);
50
+
41
51
  return r.error ? -1 : r.end;
42
52
  }
43
53
 
44
54
  function skipComment(text, i) {
45
55
  if (text[i + 1] === "/") {
46
56
  const nl = text.indexOf("\n", i);
57
+
47
58
  return nl < 0 ? text.length : nl;
48
59
  }
60
+
49
61
  const end = text.indexOf("*/", i + 2);
62
+
50
63
  return end < 0 ? text.length : end + 2;
51
64
  }
52
65
 
53
66
  function skipRegex(text, i) {
54
67
  let inClass = false;
68
+
55
69
  for (let j = i + 1; j < text.length; j++) {
56
70
  const c = text[j];
71
+
57
72
  if (c === "\\") {
58
73
  j++;
59
74
  continue;
60
75
  }
76
+
61
77
  if (c === "\n") return -1;
78
+
62
79
  if (c === "[") inClass = true;
63
80
  else if (c === "]") inClass = false;
64
81
  else if (c === "/" && !inClass) return j + 1;
65
82
  }
83
+
66
84
  return -1;
67
85
  }
68
86
 
69
87
  function lineOf(text, i) {
70
88
  let n = 1;
89
+
71
90
  for (let j = 0; j < i && j < text.length; j++) if (text[j] === "\n") n++;
91
+
72
92
  return n;
73
93
  }
74
94
 
75
95
  const IDENT_START = /[A-Za-z_$]/;
96
+
76
97
  const IDENT_PART = /[\w$]/;
77
98
 
78
99
  function readIdentifier(text, i) {
79
100
  let j = i + 1;
101
+
80
102
  while (j < text.length && IDENT_PART.test(text[j])) j++;
103
+
81
104
  return j;
82
105
  }
83
106
 
84
107
  function consumeQuoted(text, i, stack) {
85
108
  const c = text[i];
109
+
86
110
  if (c === "`") {
87
111
  const end = skipTemplate(text, i, stack);
112
+
88
113
  return end < 0 ? { error: "unterminated template literal", at: i } : { end, prev: "value" };
89
114
  }
115
+
90
116
  const end = skipString(text, i, c);
117
+
91
118
  return end < 0 ? { error: "unterminated string", at: i } : { end, prev: "value" };
92
119
  }
93
120
 
94
121
  /** Try to consume a comment, string, template, or regex at i. Returns { end, prev } | { error, at } | null. */
95
122
  function consumeLiteral(text, i, stack, prev) {
96
123
  const c = text[i];
124
+
97
125
  if (c === '"' || c === "'" || c === "`") return consumeQuoted(text, i, stack);
126
+
98
127
  if (c !== "/") return null;
128
+
99
129
  if (text[i + 1] === "/" || text[i + 1] === "*") return { end: skipComment(text, i), prev };
130
+
100
131
  if (prev !== "" && !REGEX_PRECEDERS.has(prev)) return null;
101
132
  const end = skipRegex(text, i);
133
+
102
134
  return end > 0 ? { end, prev: "value" } : null;
103
135
  }
104
136
 
@@ -106,12 +138,17 @@ function consumeLiteral(text, i, stack, prev) {
106
138
  function bracket(c, i, stack, stopDepth) {
107
139
  if (OPEN[c]) {
108
140
  stack.push({ c, at: i });
141
+
109
142
  return null;
110
143
  }
144
+
111
145
  if (!CLOSE.has(c)) return null;
112
146
  const top = stack.pop();
147
+
113
148
  if (!top || OPEN[top.c] !== c) return { error: "unexpected '" + c + "'", at: i };
149
+
114
150
  if (stopDepth !== undefined && stack.length <= stopDepth) return { end: i + 1 };
151
+
115
152
  return null;
116
153
  }
117
154
 
@@ -119,26 +156,33 @@ function bracket(c, i, stack, stopDepth) {
119
156
  function scan(text, start, stack, stopDepth) {
120
157
  let i = start;
121
158
  let prev = "";
159
+
122
160
  while (i < text.length) {
123
161
  const c = text[i];
124
162
  const literal = consumeLiteral(text, i, stack, prev);
163
+
125
164
  if (literal) {
126
165
  if (literal.error) return literal;
127
166
  i = literal.end;
128
167
  prev = literal.prev;
129
168
  continue;
130
169
  }
170
+
131
171
  if (IDENT_START.test(c)) {
132
172
  const j = readIdentifier(text, i);
133
173
  prev = text.slice(i, j);
134
174
  i = j;
135
175
  continue;
136
176
  }
177
+
137
178
  const outcome = bracket(c, i, stack, stopDepth);
179
+
138
180
  if (outcome) return outcome;
181
+
139
182
  if (!/\s/.test(c)) prev = c;
140
183
  i++;
141
184
  }
185
+
142
186
  return { end: i };
143
187
  }
144
188
 
@@ -149,18 +193,24 @@ export function quickCheck(text, ext) {
149
193
  if (ext === ".json") {
150
194
  try {
151
195
  JSON.parse(text);
196
+
152
197
  return { ok: true, kind: "json" };
153
198
  } catch (err) {
154
199
  return { ok: false, kind: "json", message: String(err.message).replace(/^JSON\.parse: /, "") };
155
200
  }
156
201
  }
202
+
157
203
  if (!CODE_EXT.has(ext)) return null;
158
204
  const stack = [];
159
205
  const r = scan(text, 0, stack);
206
+
160
207
  if (r.error) return { ok: false, kind: "balance", message: r.error + " at line " + lineOf(text, r.at) };
208
+
161
209
  if (stack.length) {
162
210
  const top = stack[stack.length - 1];
211
+
163
212
  return { ok: false, kind: "balance", message: "unclosed '" + top.c + "' opened at line " + lineOf(text, top.at) };
164
213
  }
214
+
165
215
  return { ok: true, kind: "balance" };
166
216
  }
package/src/fs/diff.js CHANGED
@@ -10,6 +10,7 @@ export function buildEditDiff(filePath, originalText, oldText, newText) {
10
10
  const newLines = contentLines(newText);
11
11
 
12
12
  const lines = [];
13
+
13
14
  if (startLine > 1 && fileLines.length >= startLine - 1) {
14
15
  lines.push({ type: "context", lineNum: startLine - 1, text: fileLines[startLine - 2] });
15
16
  }
@@ -17,11 +18,13 @@ export function buildEditDiff(filePath, originalText, oldText, newText) {
17
18
  for (let i = 0; i < oldLines.length; i++) {
18
19
  lines.push({ type: "remove", lineNum: startLine + i, text: oldLines[i] });
19
20
  }
21
+
20
22
  for (let i = 0; i < newLines.length; i++) {
21
23
  lines.push({ type: "add", lineNum: startLine + i, text: newLines[i] });
22
24
  }
23
25
 
24
26
  const afterSourceLine = startLine + oldLines.length;
27
+
25
28
  if (fileLines.length >= afterSourceLine) {
26
29
  lines.push({ type: "context", lineNum: startLine + newLines.length, text: fileLines[afterSourceLine - 1] });
27
30
  }
@@ -39,16 +42,20 @@ export function buildMultiEditDiff(filePath, originalText, replacements) {
39
42
  const parts = replacements.map(({ oldText, newText }) =>
40
43
  buildEditDiff(filePath, originalText, oldText, newText),
41
44
  );
45
+
42
46
  const lines = [];
43
47
  let shift = 0;
48
+
44
49
  for (const [index, part] of parts.entries()) {
45
50
  for (const line of part.lines) {
46
51
  if (line.type === "context") continue;
47
52
  lines.push(line.type === "add" ? { ...line, lineNum: line.lineNum + shift }
48
53
  : { ...line, newLineNum: Math.max(1, line.lineNum + shift) });
49
54
  }
55
+
50
56
  shift += replacements[index].newText.split("\n").length - replacements[index].oldText.split("\n").length;
51
57
  }
58
+
52
59
  return {
53
60
  path: filePath,
54
61
  op: "edit",
@@ -75,6 +82,7 @@ export function buildPatchDiff(filePath, patchText) {
75
82
 
76
83
  for (const patchLine of patchLines) {
77
84
  const headerMatch = (/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?/).exec(patchLine);
85
+
78
86
  if (headerMatch) {
79
87
  // A zero-length range names the line before the insertion/deletion point.
80
88
  oldLineNum = Number(headerMatch[1]) + Number(headerMatch[2] === "0");
@@ -82,9 +90,12 @@ export function buildPatchDiff(filePath, patchText) {
82
90
  inHunk = true;
83
91
  continue;
84
92
  }
93
+
85
94
  if (!inHunk || patchLine.startsWith("\\")) continue;
86
95
  const kind = classifyPatchLine(patchLine);
96
+
87
97
  if (!kind) continue;
98
+
88
99
  if (kind === "remove") {
89
100
  removed += 1;
90
101
  lines.push({ type: "remove", lineNum: oldLineNum, newLineNum, text: patchLine.slice(1) });
@@ -106,7 +117,9 @@ export function buildPatchDiff(filePath, patchText) {
106
117
  function contentLines(text) {
107
118
  if (!isString(text) || text.length === 0) return [];
108
119
  const lines = text.replace(/\r\n/g, "\n").split("\n");
120
+
109
121
  if (lines.at(-1) === "") lines.pop();
122
+
110
123
  return lines;
111
124
  }
112
125
 
@@ -115,12 +128,15 @@ export function buildWriteDiff(filePath, previousText, newText) {
115
128
  const oldLines = contentLines(previousText);
116
129
  const maxStoredLines = 64;
117
130
  const lines = [];
131
+
118
132
  for (let i = 0; i < oldLines.length && lines.length < maxStoredLines; i++) {
119
133
  lines.push({ type: "remove", lineNum: i + 1, text: oldLines[i] });
120
134
  }
135
+
121
136
  for (let i = 0; i < newLines.length && lines.length < maxStoredLines; i++) {
122
137
  lines.push({ type: "add", lineNum: i + 1, text: newLines[i] });
123
138
  }
139
+
124
140
  return {
125
141
  path: filePath,
126
142
  op: "write",
@@ -0,0 +1,91 @@
1
+ import { isString, isObject } from "../shared/decode.js";
2
+
3
+ export const MAX_JSON_BYTES = 16 * 1024 * 1024;
4
+
5
+ const SELECTOR_HELP = 'JSON selector supports .field, .nested[0], .items[0:3], .["quoted.key"], or . (whole value); not full jq';
6
+
7
+ /** Parse a small, non-evaluating selector language. No dynamic code or prototype lookup. */
8
+ function parseSelector(selector) {
9
+ if (!isString(selector) || !selector.startsWith(".") || selector.length > 2048) throw new Error(SELECTOR_HELP);
10
+ let rest = selector.slice(1);
11
+ const steps = [];
12
+ let first = true;
13
+
14
+ while (rest) {
15
+ let match;
16
+
17
+ if ((match = (first ? /^([A-Za-z_$][\w$]*)/ : /^\.([A-Za-z_$][\w$]*)/).exec(rest))) {
18
+ steps.push({ key: match[1] });
19
+ } else if ((match = /^\[("(?:[^"\\]|\\.)*")\]/.exec(rest))) {
20
+ try { steps.push({ key: JSON.parse(match[1]) }); } catch { throw new Error(SELECTOR_HELP); }
21
+ } else if ((match = /^\[(\d+)(?::(\d+))?\]/.exec(rest))) {
22
+ const start = Number(match[1]), end = match[2] === undefined ? undefined : Number(match[2]);
23
+
24
+ if (!Number.isSafeInteger(start) || (end !== undefined && (!Number.isSafeInteger(end) || end < start))) throw new Error(SELECTOR_HELP);
25
+ steps.push(end === undefined ? { index: start } : { start, end });
26
+ } else throw new Error(SELECTOR_HELP);
27
+ rest = rest.slice(match[0].length);
28
+ first = false;
29
+ }
30
+
31
+ return steps;
32
+ }
33
+
34
+ export function jsonProjector(json) {
35
+ const many = Array.isArray(json);
36
+ const selectors = many ? json : [json === true ? "." : json];
37
+
38
+ if (!selectors.length || selectors.length > 64) throw new Error("JSON selector list requires 1 to 64 selectors");
39
+ const plans = Array.from(selectors, parseSelector);
40
+
41
+ // Yield one selection at a time so the caller can budget it before the next
42
+ // slice allocation. Eagerly mapping 64 large slices can exhaust the host heap.
43
+ return function* (root) {
44
+ for (const steps of plans) yield steps.reduce((value, step) => {
45
+ if (step.key !== undefined) {
46
+ if (!isObject(value) || !Object.hasOwn(value, step.key)) {
47
+ const keys = isObject(value) ? Object.keys(value) : [];
48
+ const preview = keys.length ? "; available keys: " + keys.slice(0, 24).map(key => JSON.stringify(key)).join(", ") + (keys.length > 24 ? ", …" : "") : "";
49
+ throw new Error("JSON field not found: " + JSON.stringify(step.key) + preview);
50
+ }
51
+
52
+ return value[step.key];
53
+ }
54
+
55
+ if (!Array.isArray(value)) throw new Error("JSON index requires an array");
56
+
57
+ if (step.index !== undefined) {
58
+ if (step.index >= value.length) throw new Error("JSON index out of range: " + step.index);
59
+
60
+ return value[step.index];
61
+ }
62
+
63
+ return value.slice(step.start, step.end);
64
+ }, root);
65
+ };
66
+ }
67
+
68
+ export function sessionJsonArgs(args) {
69
+ if (!isString(args.path) || !/^(agent|artifact):\/\//i.test(args.path) || !args.path.includes("?")) return args;
70
+ const [uri, query] = args.path.split("?");
71
+ const params = new URLSearchParams(query);
72
+
73
+ if (args.path.includes("#") || args.path.split("?").length !== 2 || params.size !== 1 || !params.has("q") || args.json !== undefined) {
74
+ throw new Error("session resource supports only one ?q=<JSON selector>; do not combine it with json");
75
+ }
76
+
77
+ const json = params.get("q");
78
+ jsonProjector(json); // Reject invalid selectors before artifact lookup.
79
+
80
+ return { ...args, path: uri, json };
81
+ }
82
+
83
+ export function validateJsonRead(args) {
84
+ if (args.json === undefined) return;
85
+
86
+ if (["offset", "limit", "about", "query", "outline", "evidence", "resolve", "complete"].some(key => args[key] !== undefined)) {
87
+ throw new Error("JSON reads cannot combine json with line windows, source views, or complete; select fields after parsing the whole document");
88
+ }
89
+
90
+ jsonProjector(args.json);
91
+ }
package/src/fs/patch.js CHANGED
@@ -2,7 +2,9 @@ import { isString } from "../shared/decode.js";
2
2
 
3
3
  function parseHunkHeader(line) {
4
4
  const match = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
5
+
5
6
  if (!match) return null;
7
+
6
8
  return { oldStart: Number(match[1]), oldLength: match[2] === undefined ? 1 : Number(match[2]),
7
9
  newStart: Number(match[3]), newLength: match[4] === undefined ? 1 : Number(match[4]), lines: [], noNewline: [] };
8
10
  }
@@ -12,8 +14,10 @@ export function parsePatchHunks(patchText) {
12
14
  let current;
13
15
  let oldCount = 0;
14
16
  let newCount = 0;
17
+
15
18
  for (const line of patchText.split("\n")) {
16
19
  const header = parseHunkHeader(line);
20
+
17
21
  if (header) {
18
22
  current = header;
19
23
  hunks.push(current);
@@ -26,12 +30,17 @@ export function parsePatchHunks(patchText) {
26
30
  if (oldCount === current.oldLength && newCount === current.newLength && /^--- |^\+\+\+ /.test(line)) {
27
31
  throw new Error("apply_patch accepts one file at a time");
28
32
  }
33
+
29
34
  current.lines.push(line);
35
+
30
36
  if (line[0] !== "+") oldCount++;
37
+
31
38
  if (line[0] !== "-") newCount++;
32
39
  }
33
40
  }
41
+
34
42
  if (!hunks.length) throw new Error("no valid patch hunks found (expected @@ -old,len +new,len @@)");
43
+
35
44
  return hunks;
36
45
  }
37
46
 
@@ -39,10 +48,13 @@ function splitFile(text) {
39
48
  if (!text) return [];
40
49
  const chunks = text.split("\n");
41
50
  const trailing = chunks.at(-1) === "";
51
+
42
52
  if (trailing) chunks.pop();
53
+
43
54
  return chunks.map((chunk, index) => {
44
55
  const newline = index < chunks.length - 1 || trailing;
45
56
  const crlf = newline && chunk.endsWith("\r");
57
+
46
58
  return { text: crlf ? chunk.slice(0, -1) : chunk, ending: newline ? (crlf ? "\r\n" : "\n") : "" };
47
59
  });
48
60
  }
@@ -50,12 +62,17 @@ function splitFile(text) {
50
62
  function findHunkMatch(fileLines, expectedOld, nominal) {
51
63
  const matchAt = index => index >= 0 && index + expectedOld.length <= fileLines.length
52
64
  && expectedOld.every((line, i) => fileLines[index + i].text === line);
65
+
53
66
  if (matchAt(nominal)) return nominal;
67
+
54
68
  if (!expectedOld.length) return -1;
69
+
55
70
  for (let delta = 1; delta <= Math.max(fileLines.length, 100); delta++) {
56
71
  if (matchAt(nominal + delta)) return nominal + delta;
72
+
57
73
  if (matchAt(nominal - delta)) return nominal - delta;
58
74
  }
75
+
59
76
  return -1;
60
77
  }
61
78
 
@@ -66,31 +83,40 @@ export function applyPatchToText(originalText, patchText) {
66
83
  const ending = fileLines.find(line => line.ending)?.ending ?? "\n";
67
84
  let offsetShift = 0;
68
85
  let relocationShift = 0;
86
+
69
87
  for (let h = 0; h < hunks.length; h++) {
70
88
  const hunk = hunks[h];
71
89
  const textOf = line => line.slice(1).replace(/\r$/, "");
72
90
  const expectedOld = hunk.lines.filter(line => line[0] !== "+").map(textOf);
73
91
  const newCount = hunk.lines.filter(line => line[0] !== "-").length;
92
+
74
93
  if (expectedOld.length !== hunk.oldLength || newCount !== hunk.newLength) throw new Error("patch hunk " + (h + 1) + " length does not match its header");
75
94
  // The new coordinate also handles BSD diff's -1,0 header at file start.
76
95
  const nominal = hunk.oldLength === 0 ? hunk.newStart - 1 + relocationShift : hunk.oldStart - 1 + offsetShift;
77
96
  const matchIndex = findHunkMatch(fileLines, expectedOld, nominal);
97
+
78
98
  if (matchIndex < 0) throw new Error("patch hunk " + (h + 1) + " rejected at line " + hunk.oldStart + ": context did not match");
79
99
  const replacement = [];
80
100
  let oldIndex = matchIndex;
101
+
81
102
  for (let i = 0; i < hunk.lines.length; i++) {
82
103
  const line = hunk.lines[i];
104
+
83
105
  if (line[0] === "+") {
84
106
  replacement.push({ text: textOf(line), ending: hunk.noNewline.includes(i) ? "" : line.endsWith("\r") ? "\r\n" : ending });
85
107
  } else {
86
108
  const original = fileLines[oldIndex++];
109
+
87
110
  if (hunk.noNewline.includes(i) && original.ending) throw new Error("patch newline marker does not match the file");
111
+
88
112
  if (line[0] === " ") replacement.push(original);
89
113
  }
90
114
  }
115
+
91
116
  fileLines.splice(matchIndex, expectedOld.length, ...replacement);
92
117
  relocationShift += matchIndex - nominal;
93
118
  offsetShift += matchIndex - nominal + replacement.length - expectedOld.length;
94
119
  }
120
+
95
121
  return { resultText: fileLines.map(line => line.text + line.ending).join(""), hunkCount: hunks.length };
96
122
  }