@plurnk/plurnk-schemes 1.3.11 → 1.4.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.
Files changed (71) hide show
  1. package/README.md +25 -14
  2. package/SPEC.md +311 -75
  3. package/dist/DefaultRead.d.ts +8 -4
  4. package/dist/DefaultRead.d.ts.map +1 -1
  5. package/dist/DefaultRead.js +37 -13
  6. package/dist/DefaultRead.js.map +1 -1
  7. package/dist/Manifest.d.ts +6 -0
  8. package/dist/Manifest.d.ts.map +1 -0
  9. package/dist/Manifest.js +115 -0
  10. package/dist/Manifest.js.map +1 -0
  11. package/dist/Matcher.d.ts +5 -6
  12. package/dist/Matcher.d.ts.map +1 -1
  13. package/dist/Matcher.js +51 -59
  14. package/dist/Matcher.js.map +1 -1
  15. package/dist/MimetypeClassifier.d.ts +1 -1
  16. package/dist/MimetypeClassifier.d.ts.map +1 -1
  17. package/dist/MimetypeClassifier.js +11 -15
  18. package/dist/MimetypeClassifier.js.map +1 -1
  19. package/dist/NetworkAddress.d.ts +13 -0
  20. package/dist/NetworkAddress.d.ts.map +1 -0
  21. package/dist/NetworkAddress.js +43 -0
  22. package/dist/NetworkAddress.js.map +1 -0
  23. package/dist/OutputScheme.d.ts.map +1 -1
  24. package/dist/OutputScheme.js +5 -6
  25. package/dist/OutputScheme.js.map +1 -1
  26. package/dist/PacketSections.d.ts +6 -0
  27. package/dist/PacketSections.d.ts.map +1 -0
  28. package/dist/PacketSections.js +42 -0
  29. package/dist/PacketSections.js.map +1 -0
  30. package/dist/PathMimetype.js +9 -9
  31. package/dist/PathMimetype.js.map +1 -1
  32. package/dist/Results.d.ts +29 -8
  33. package/dist/Results.d.ts.map +1 -1
  34. package/dist/Results.js +102 -44
  35. package/dist/Results.js.map +1 -1
  36. package/dist/SchemeDiscovery.d.ts +2 -0
  37. package/dist/SchemeDiscovery.d.ts.map +1 -1
  38. package/dist/SchemeDiscovery.js +40 -42
  39. package/dist/SchemeDiscovery.js.map +1 -1
  40. package/dist/SchemeResolver.d.ts.map +1 -1
  41. package/dist/SchemeResolver.js +4 -5
  42. package/dist/SchemeResolver.js.map +1 -1
  43. package/dist/Slicer.d.ts +36 -14
  44. package/dist/Slicer.d.ts.map +1 -1
  45. package/dist/Slicer.js +330 -362
  46. package/dist/Slicer.js.map +1 -1
  47. package/dist/Summarize.js +3 -3
  48. package/dist/Summarize.js.map +1 -1
  49. package/dist/ctx.d.ts +60 -60
  50. package/dist/ctx.d.ts.map +1 -1
  51. package/dist/edit-receipt.d.ts +39 -0
  52. package/dist/edit-receipt.d.ts.map +1 -0
  53. package/dist/edit-receipt.js +2 -0
  54. package/dist/edit-receipt.js.map +1 -0
  55. package/dist/handler.d.ts +7 -8
  56. package/dist/handler.d.ts.map +1 -1
  57. package/dist/handler.js +4 -2
  58. package/dist/handler.js.map +1 -1
  59. package/dist/index.d.ts +13 -5
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +6 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/packet.d.ts +2 -3
  64. package/dist/packet.d.ts.map +1 -1
  65. package/dist/packet.js +2 -20
  66. package/dist/packet.js.map +1 -1
  67. package/dist/types.d.ts +3 -11
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js +1 -7
  70. package/dist/types.js.map +1 -1
  71. package/package.json +10 -8
package/dist/Slicer.js CHANGED
@@ -5,49 +5,193 @@
5
5
  // <0> sentinel: before position 1 (EDIT prepend)
6
6
  // <-1> sentinel: after the last position (EDIT append)
7
7
  // <1,-1> every position (in range context, -1 normalizes to last line)
8
- // <N.frac> fractional single mark: an INSERT POINT between lines, not a line
9
- // to replace (#18). EDIT inserts after line floor(N.frac), replacing
10
- // nothing (<2.5> = between lines 2 and 3; <0.5> = prepend; <T.5> at
11
- // T = totalLines = append). For READ it selects no content, like the
12
- // <0>/<-1> sentinels.
8
+ // <SL,SC,EL,EC> selects an exact text region with an exclusive end.
9
+ //
10
+ // Runtime tolerance (not canonical producer syntax): <SL,SC,EL> is accepted
11
+ // as <SL,SC> through the end of line EL and lowered immediately to four
12
+ // coordinates. Successful results carry the exact normalization evidence.
13
13
  //
14
14
  // "N and M are signed integers" — but plurnk.md only documents <0> and
15
15
  // <-1> as defined sentinels. Other negatives (<-2>, <-3>) are not
16
16
  // specified and rejected as 416. Within a range, -1 as the M endpoint
17
17
  // means "include through the last line" (so <1,-1> is whole content).
18
+ import { TextCoordinates } from "@plurnk/plurnk-mimetypes";
19
+ import Results from "./Results.js";
18
20
  export default class Slicer {
19
- static #splitLines(content) {
20
- const trailingNewline = content.endsWith("\n");
21
- if (content === "")
22
- return { lines: [], trailingNewline: false };
23
- const lines = content.split("\n");
24
- if (trailingNewline)
25
- lines.pop();
26
- return { lines, trailingNewline };
21
+ static #failure(code, status, detail, fields = {}, extensions = {}) {
22
+ return Results.failure("schemes:slicer", code, status, detail, fields, extensions);
23
+ }
24
+ static #rangeFailure(detail, range) {
25
+ return Slicer.#failure("range-not-satisfiable", 416, detail, { range }, {
26
+ range,
27
+ stage: "projection",
28
+ recovery: "Choose a range within the available extent.",
29
+ retryable: false,
30
+ });
31
+ }
32
+ static #regionFailure(detail, marker) {
33
+ return Slicer.#failure("range-not-satisfiable", 416, detail, {}, {
34
+ requestedCoordinates: marker.marks,
35
+ columnKind: "unicodeCodePoints",
36
+ stage: "projection",
37
+ recovery: "Choose a region within the available text.",
38
+ retryable: false,
39
+ });
40
+ }
41
+ static #extent(marker, total, unit) {
42
+ return {
43
+ unit,
44
+ requested: {
45
+ first: marker.marks[0],
46
+ last: marker.marks.length > 1 ? marker.marks[1] : null,
47
+ },
48
+ available: {
49
+ first: total === 0 ? null : 1,
50
+ last: total === 0 ? null : total,
51
+ total,
52
+ },
53
+ };
54
+ }
55
+ static #exactTextReplacement(content, marker, body) {
56
+ const [startLine, startColumn, endLine, endColumn] = marker.marks;
57
+ if (![startLine, startColumn, endLine, endColumn].every(Number.isSafeInteger)) {
58
+ return { error: "An exact text region requires four integer coordinates." };
59
+ }
60
+ const lines = TextCoordinates.lines(content);
61
+ if (startLine < 1 || startLine > lines.length) {
62
+ return { error: `Start line ${startLine} is outside the available line range 1..${lines.length}.` };
63
+ }
64
+ if (endLine < 1 || endLine > lines.length) {
65
+ return { error: `End line ${endLine} is outside the available line range 1..${lines.length}.` };
66
+ }
67
+ let start;
68
+ let end;
69
+ try {
70
+ start = TextCoordinates.offsetAtPosition(content, startLine, startColumn);
71
+ end = TextCoordinates.offsetAtPosition(content, endLine, endColumn);
72
+ }
73
+ catch (cause) {
74
+ return { error: cause instanceof Error ? cause.message : String(cause) };
75
+ }
76
+ if (end < start) {
77
+ return {
78
+ error: `Exact region ${startLine},${startColumn},${endLine},${endColumn} ends before it starts.`,
79
+ };
80
+ }
81
+ return { start, end, body, startLine, endLine };
82
+ }
83
+ static #preferredSeparator(content, lines) {
84
+ return lines.find(({ separator }) => separator.length > 0)?.separator
85
+ ?? (content.includes("\r\n") ? "\r\n" : content.includes("\r") ? "\r" : "\n");
86
+ }
87
+ static #lineTextReplacement(content, marker, body) {
88
+ const lines = TextCoordinates.logicalLines(content);
89
+ const norm = Slicer.#normalize(marker, lines.length);
90
+ if ("error" in norm)
91
+ return norm;
92
+ const separator = Slicer.#preferredSeparator(content, lines);
93
+ if (norm.kind === "before-first") {
94
+ const inserted = body.length > 0 && content.length > 0 && !/[\r\n]$/.test(body)
95
+ ? `${body}${separator}`
96
+ : body;
97
+ return { start: 0, end: 0, body: inserted, startLine: 1, endLine: 1 };
98
+ }
99
+ if (norm.kind === "after-last") {
100
+ const prefix = body.length > 0 && content.length > 0 && !/[\r\n]$/.test(content)
101
+ ? separator
102
+ : "";
103
+ const suffix = body.length > 0 && /[\r\n]$/.test(content) && !/[\r\n]$/.test(body)
104
+ ? separator
105
+ : "";
106
+ return {
107
+ start: content.length,
108
+ end: content.length,
109
+ body: `${prefix}${body}${suffix}`,
110
+ startLine: Math.max(lines.length, 1),
111
+ endLine: Math.max(lines.length, 1),
112
+ };
113
+ }
114
+ const first = lines[norm.start - 1];
115
+ const last = lines[norm.end - 1];
116
+ if (first === undefined || last === undefined) {
117
+ if (lines.length === 0 && norm.start === 1 && norm.end === 0) {
118
+ return { start: 0, end: 0, body, startLine: 1, endLine: 1 };
119
+ }
120
+ return { error: `Line range ${norm.start},${norm.end} cannot be resolved.` };
121
+ }
122
+ const inserted = body.length > 0 && last.separator.length > 0 && !/[\r\n]$/.test(body)
123
+ ? `${body}${last.separator}`
124
+ : body;
125
+ return {
126
+ start: first.start,
127
+ end: last.end,
128
+ body: inserted,
129
+ startLine: norm.start,
130
+ endLine: norm.end,
131
+ };
132
+ }
133
+ static textReplacement(content, marker, body) {
134
+ if (marker.marks.length === 3) {
135
+ const expanded = Slicer.#expandRegion3(content, marker);
136
+ if ("error" in expanded)
137
+ return expanded;
138
+ const replacement = Slicer.#exactTextReplacement(content, expanded.marker, body);
139
+ return "error" in replacement
140
+ ? replacement
141
+ : { ...replacement, normalization: expanded.normalization };
142
+ }
143
+ if (marker.marks.length === 4) {
144
+ return Slicer.#exactTextReplacement(content, marker, body);
145
+ }
146
+ if (marker.marks.length === 1 || marker.marks.length === 2) {
147
+ return Slicer.#lineTextReplacement(content, marker, body);
148
+ }
149
+ return {
150
+ error: `Text regions require one, two, or four coordinates; received ${marker.marks.length}.`,
151
+ };
152
+ }
153
+ static #expandRegion3(content, marker) {
154
+ const [startLine, startColumn, endLine] = marker.marks;
155
+ if (![startLine, startColumn, endLine].every(Number.isSafeInteger)) {
156
+ return { error: "A tolerated three-coordinate text region requires integer coordinates." };
157
+ }
158
+ const lines = TextCoordinates.lines(content);
159
+ const last = lines[endLine - 1];
160
+ if (endLine < 1 || last === undefined) {
161
+ return { error: `End line ${endLine} is outside the available line range 1..${lines.length}.` };
162
+ }
163
+ const endColumn = [...content.slice(last.start, last.contentEnd)].length + 1;
164
+ const requested = [startLine, startColumn, endLine];
165
+ const canonical = [startLine, startColumn, endLine, endColumn];
166
+ return {
167
+ marker: { ...marker, marks: [...canonical] },
168
+ normalization: { requested, canonical },
169
+ };
27
170
  }
28
171
  static #normalize(marker, totalLines) {
29
- // grammar 0.49 carries raw `marks: [number, ...]` and punts role
30
- // assignment to us (#19): marks[0] = first/position, marks[1] = last
172
+ // {§slicer-text-algebra} The parser carries raw `marks: [number, ...]`;
173
+ // this owner assigns roles: marks[0] = first/position, marks[1] = last
31
174
  // (range end). A single mark is a position/sentinel; two is a range.
175
+ if (marker.marks.length !== 1 && marker.marks.length !== 2) {
176
+ return { error: `A positional range requires one or two coordinates; received ${marker.marks.length}.` };
177
+ }
32
178
  const first = marker.marks[0];
33
179
  const last = marker.marks.length > 1 ? marker.marks[1] : null;
180
+ if (!Number.isInteger(first) || (last !== null && !Number.isInteger(last))) {
181
+ return { error: "Whole-line scopes require integer coordinates." };
182
+ }
34
183
  if (last === null) {
35
184
  if (first === 0)
36
185
  return { kind: "before-first", start: 0, end: 0 };
37
186
  if (first === -1)
38
187
  return { kind: "after-last", start: totalLines, end: totalLines };
39
- // Fractional single mark <N.frac> is an insert point between lines,
40
- // not a position to replace (#18): insert after line floor(N.frac).
41
- // floor 0 = prepend, floor totalLines = append.
42
- if (!Number.isInteger(first)) {
43
- const at = Math.floor(first);
44
- if (first > 0 && at <= totalLines)
45
- return { kind: "insert-between", start: at, end: at };
46
- return { error: `insert point ${first} out of range (0..${totalLines})` };
47
- }
48
188
  if (first > 0 && first <= totalLines)
49
189
  return { kind: "range", start: first, end: first };
50
- return { error: `line ${first} out of range (1..${totalLines})` };
190
+ return {
191
+ error: totalLines === 0
192
+ ? `Line ${first} cannot select from empty content.`
193
+ : `Line ${first} is outside the available line range 1..${totalLines}.`,
194
+ };
51
195
  }
52
196
  // Whole-content `<1,-1>` (and its `<0,-1>` alias) is valid even on
53
197
  // empty content — it selects the entire, possibly-empty range. Without
@@ -57,7 +201,7 @@ export default class Slicer {
57
201
  if (totalLines === 0) {
58
202
  if ((first === 0 || first === 1) && last === -1)
59
203
  return { kind: "range", start: 1, end: 0 };
60
- return { error: `range ${first},${last} out of range (content is empty)` };
204
+ return { error: `Range ${first},${last} cannot select from empty content.` };
61
205
  }
62
206
  let n = first;
63
207
  let m = last;
@@ -66,390 +210,214 @@ export default class Slicer {
66
210
  if (m === -1)
67
211
  m = totalLines;
68
212
  if (n < 1 || n > totalLines)
69
- return { error: `range start ${first} out of range (1..${totalLines})` };
213
+ return { error: `Range start ${first} is outside the available line range 1..${totalLines}.` };
70
214
  if (m < 1 || m > totalLines)
71
- return { error: `range end ${last} out of range (1..${totalLines})` };
215
+ return { error: `Range end ${last} is outside the available line range 1..${totalLines}.` };
72
216
  if (n > m)
73
- return { error: `range start ${first} > end ${last}` };
217
+ return { error: `Range start ${first} exceeds end ${last}.` };
74
218
  return { kind: "range", start: n, end: m };
75
219
  }
76
- // READ a line range. Returns the raw selected lines (no `N:\t` prefix)
220
+ // READ a line range. Returns the raw selected lines (no `N:` prefix)
77
221
  // plus the 1-indexed position of the first selected line. The render
78
- // layer adds `N:\t` per plurnk.md ("READ output prefixes every line with
79
- // line numbers, N:\t") starting from `startLine` — keeps numbering as a
222
+ // layer adds `N:` per plurnk.md starting from `startLine` - keeps numbering as a
80
223
  // presentation concern, prevents double-prefixing when the same content
81
224
  // passes through the log render.
82
225
  //
83
226
  // Sentinel positions <0> and <-1> select no content (they're insertion
84
227
  // points, not lines) → status 200 with empty text.
85
228
  static lines(content, marker) {
86
- const { lines } = Slicer.#splitLines(content);
229
+ const requestedMarker = marker;
230
+ let normalization;
231
+ if (marker.marks.length === 3) {
232
+ const expanded = Slicer.#expandRegion3(content, marker);
233
+ if ("error" in expanded)
234
+ return Slicer.#regionFailure(expanded.error, requestedMarker);
235
+ marker = expanded.marker;
236
+ normalization = expanded.normalization;
237
+ }
238
+ if (marker.marks.length === 4) {
239
+ const selected = Slicer.#exactTextReplacement(content, marker, "");
240
+ if ("error" in selected) {
241
+ return Slicer.#regionFailure(selected.error, requestedMarker);
242
+ }
243
+ const region = TextCoordinates.regionFromOffsets(content, selected.start, selected.end);
244
+ if (region === null) {
245
+ throw new Error("Slicer resolved an exact replacement with no addressable TextRegion.");
246
+ }
247
+ return {
248
+ status: 200,
249
+ text: content.slice(selected.start, selected.end),
250
+ startLine: selected.startLine,
251
+ region,
252
+ ...(normalization === undefined ? {} : { scopeNormalizations: [normalization] }),
253
+ };
254
+ }
255
+ if (marker.marks.length !== 1 && marker.marks.length !== 2) {
256
+ return Slicer.#regionFailure(`Text regions require one, two, or four coordinates; received ${marker.marks.length}.`, marker);
257
+ }
258
+ const lines = TextCoordinates.logicalLines(content);
87
259
  const norm = Slicer.#normalize(marker, lines.length);
88
- if ("error" in norm)
89
- return { status: 416, error: norm.error };
260
+ if ("error" in norm) {
261
+ return Slicer.#rangeFailure(norm.error, Slicer.#extent(marker, lines.length, "line"));
262
+ }
90
263
  if (norm.kind !== "range")
91
264
  return { status: 200, text: "", startLine: undefined };
92
- const selected = lines.slice(norm.start - 1, norm.end);
93
- return { status: 200, text: selected.join("\n"), startLine: norm.start };
94
- }
95
- // Structural `<L>` slice for JSON sources (plurnk-grammar 0.13.0).
96
- // "On structured entries, <L> addresses item index, not line number."
97
- // Every JSON value becomes a list of top-level items:
98
- // array `[a, b, c]` → items are the array elements
99
- // object `{k1: v1, ...}` → items are key-value pairs (as single-key objects)
100
- // scalar `"hello"` / 42 → item is the scalar itself (length-1 list)
101
- // `<L>` indexes into that list (1-indexed). Result is always a JSON array.
102
- // Sentinels `<0>` / `<-1>` are insertion points — empty `[]` for READ.
103
- // Out-of-range positions return 416. Matches the uniform "always JSON
104
- // array out" shape we settled for matcher results.
105
- static #jsonValueToItems(parsed) {
106
- if (Array.isArray(parsed))
107
- return parsed;
108
- if (parsed !== null && typeof parsed === "object") {
109
- // Object items are single-key {key: value} wrappers, in insertion
110
- // order. Object.entries preserves spec-guaranteed iteration order
111
- // for string keys.
112
- return Object.entries(parsed).map(([k, v]) => ({ [k]: v }));
113
- }
114
- // Scalar (string, number, boolean, null): a length-1 list of itself.
115
- return [parsed];
116
- }
117
- static jsonItems(content, marker) {
118
- let parsed;
119
- try {
120
- parsed = JSON.parse(content);
265
+ if (lines.length === 0) {
266
+ const region = TextCoordinates.regionFromOffsets(content, 0, 0);
267
+ if (region === null) {
268
+ throw new Error("Slicer could not address an empty text resource.");
269
+ }
270
+ return { status: 200, text: "", startLine: 1, region };
121
271
  }
122
- catch (err) {
123
- return { status: 400, error: `malformed JSON: ${err instanceof Error ? err.message : String(err)}` };
272
+ const selected = lines
273
+ .slice(norm.start - 1, norm.end)
274
+ .map(({ start, contentEnd }) => content.slice(start, contentEnd));
275
+ const region = TextCoordinates.lineRegion(content, norm.start, norm.end);
276
+ if (region === null) {
277
+ throw new Error("Slicer resolved a whole-line selection with no addressable TextRegion.");
124
278
  }
125
- const items = Slicer.#jsonValueToItems(parsed);
279
+ return {
280
+ status: 200,
281
+ text: selected.join("\n"),
282
+ startLine: norm.start,
283
+ region,
284
+ };
285
+ }
286
+ // `<L>` over an ordered result set uses the same positional contract as
287
+ // READ slicing. The returned extent lets a scheme put exact recovery facts
288
+ // in RFC 9457 Problem Details instead of emitting a bare 416.
289
+ static page(items, marker) {
126
290
  const total = items.length;
127
- const first = marker.marks[0];
128
- const last = marker.marks.length > 1 ? marker.marks[1] : null;
291
+ const extent = Slicer.#extent(marker, total, "result");
292
+ if (marker.marks.length !== 1 && marker.marks.length !== 2) {
293
+ return Slicer.#failure("range-not-satisfiable", 416, `Result pagination requires one position or an inclusive two-position range; received ${marker.marks.length} positions.`, { range: extent }, {
294
+ range: extent,
295
+ requestedPositions: marker.marks,
296
+ stage: "projection",
297
+ recovery: "Use <N> or <N,M> to select results.",
298
+ retryable: false,
299
+ });
300
+ }
301
+ const { first, last } = extent.requested;
302
+ if (!Number.isInteger(first) || (last !== null && !Number.isInteger(last))) {
303
+ return Slicer.#rangeFailure(`Result ranges require integer positions; the result set contains ${total} item(s).`, extent);
304
+ }
129
305
  if (last === null) {
130
306
  if (first === 0 || first === -1)
131
- return { status: 200, body: "[]" };
307
+ return { status: 200, items: [] };
132
308
  if (first > 0 && first <= total)
133
- return { status: 200, body: JSON.stringify([items[first - 1]], null, 2) };
134
- return { status: 416, error: `item ${first} out of range (1..${total})` };
309
+ return { status: 200, items: [items[first - 1]] };
310
+ return Slicer.#rangeFailure(`Result ${first} is out of range; available positions are 1..${total}.`, extent);
135
311
  }
136
- // Whole-content `<1,-1>` is valid on an empty item list — it selects
137
- // the entire, empty range (mirrors #normalize's empty-content rule).
138
312
  if (total === 0) {
139
313
  if ((first === 0 || first === 1) && last === -1)
140
- return { status: 200, body: "[]" };
141
- return { status: 416, error: `range ${first},${last} out of range (no items)` };
314
+ return { status: 200, items: [] };
315
+ return Slicer.#rangeFailure(`Result range ${first},${last} cannot select from an empty result set.`, extent);
142
316
  }
143
- let n = first;
144
- let m = last;
145
- if (n === 0)
146
- n = 1;
147
- if (m === -1)
148
- m = total;
317
+ const n = first === 0 ? 1 : first;
318
+ const m = last === -1 ? total : last;
149
319
  if (n < 1 || n > total)
150
- return { status: 416, error: `range start ${first} out of range (1..${total})` };
320
+ return Slicer.#rangeFailure(`Result range start ${first} is out of range; available positions are 1..${total}.`, extent);
151
321
  if (m < 1 || m > total)
152
- return { status: 416, error: `range end ${last} out of range (1..${total})` };
322
+ return Slicer.#rangeFailure(`Result range end ${last} is out of range; available positions are 1..${total}.`, extent);
153
323
  if (n > m)
154
- return { status: 416, error: `range start ${first} > end ${last}` };
155
- return { status: 200, body: JSON.stringify(items.slice(n - 1, m), null, 2) };
324
+ return Slicer.#rangeFailure(`Result range start ${first} exceeds end ${last}.`, extent);
325
+ return { status: 200, items: items.slice(n - 1, m) };
156
326
  }
157
- // Structural `<L>` EDIT for JSON sources (plurnk-grammar 0.13.0/0.14.0).
158
- // Source-shape rules (matches jsonItems' item definition):
159
- // array → items are elements
160
- // object → items are key-value pairs (single-key fragments)
161
- // scalar → length-1 list of itself; grow markers (<0>,<-1>) reject
162
- //
163
- // Body shape (Resolution B):
164
- // body parses as JSON array → those are the items to splice in
165
- // body parses as non-array JSON → single item to splice in
166
- // empty body → delete the selection
167
- // body fails JSON parse → 400 (path-extension declares intent; honor it)
168
- //
169
- // Marker semantics (parallel to line-EDIT):
170
- // <N> replace item N with body item(s)
171
- // <N,M> replace items N..M with body item(s)
172
- // <0> prepend body item(s)
173
- // <-1> append body item(s)
174
- // <1,-1> replace whole top-level with body item(s); empty body clears
175
- // Empty body on a sentinel insertion (<0> or <-1>) → no-op.
176
- static #itemsFromBody(body) {
177
- if (body === "")
178
- return { items: [] }; // empty body = delete
179
- let parsed;
180
- try {
181
- parsed = JSON.parse(body);
182
- }
183
- catch (err) {
184
- return { error: `malformed JSON body: ${err instanceof Error ? err.message : String(err)}` };
185
- }
186
- if (Array.isArray(parsed))
187
- return { items: parsed };
188
- return { items: [parsed] };
189
- }
190
- static #applyJsonArrayEdit(source, marker, items) {
191
- const total = source.length;
192
- const first = marker.marks[0];
193
- const last = marker.marks.length > 1 ? marker.marks[1] : null;
194
- let result;
195
- if (last === null) {
196
- if (first === 0)
197
- result = [...items, ...source];
198
- else if (first === -1)
199
- result = [...source, ...items];
200
- else if (first > 0 && first <= total)
201
- result = [...source.slice(0, first - 1), ...items, ...source.slice(first)];
202
- else
203
- return { status: 416, error: `position ${first} out of range (1..${total})` };
204
- }
205
- else if (total === 0) {
206
- // Empty array: only whole-content `<1,-1>` (or `<0,-1>` alias) is
207
- // valid — it replaces the (empty) whole with the body items. Any
208
- // other range on an empty array is out of range.
209
- if ((first !== 1 && first !== 0) || last !== -1)
210
- return { status: 416, error: `range on empty array` };
211
- result = [...items];
212
- }
213
- else {
214
- let n = first;
215
- let m = last;
216
- if (n === 0)
217
- n = 1;
218
- if (m === -1)
219
- m = total;
220
- if (n < 1 || n > total)
221
- return { status: 416, error: `range start ${first} out of range (1..${total})` };
222
- if (m < 1 || m > total)
223
- return { status: 416, error: `range end ${last} out of range (1..${total})` };
224
- if (n > m)
225
- return { status: 416, error: `range start ${first} > end ${last}` };
226
- result = [...source.slice(0, n - 1), ...items, ...source.slice(m)];
227
- }
228
- return { status: 200, result: JSON.stringify(result, null, 2) };
229
- }
230
- static #applyJsonObjectEdit(source, marker, items) {
231
- // Object items are key-value pairs. Body items must be objects;
232
- // each object's entries become kv-pairs to splice in. Items that
233
- // aren't single objects → 400 (model used wrong body shape for an
234
- // object source).
235
- const bodyEntries = [];
236
- for (const item of items) {
237
- if (item === null || typeof item !== "object" || Array.isArray(item)) {
238
- return { status: 400, error: "object source requires body items to be JSON objects (key-value pairs)" };
239
- }
240
- bodyEntries.push(...Object.entries(item));
241
- }
242
- const entries = Object.entries(source);
243
- const total = entries.length;
244
- const first = marker.marks[0];
245
- const last = marker.marks.length > 1 ? marker.marks[1] : null;
246
- let result;
247
- if (last === null) {
248
- if (first === 0)
249
- result = [...bodyEntries, ...entries];
250
- else if (first === -1)
251
- result = [...entries, ...bodyEntries];
252
- else if (first > 0 && first <= total)
253
- result = [...entries.slice(0, first - 1), ...bodyEntries, ...entries.slice(first)];
254
- else
255
- return { status: 416, error: `position ${first} out of range (1..${total})` };
256
- }
257
- else if (total === 0) {
258
- // Empty object: only whole-content `<1,-1>` (or `<0,-1>` alias) is
259
- // valid — replaces the (empty) whole with the body kv-pairs.
260
- if ((first !== 1 && first !== 0) || last !== -1)
261
- return { status: 416, error: `range on empty object` };
262
- result = [...bodyEntries];
263
- }
264
- else {
265
- let n = first;
266
- let m = last;
267
- if (n === 0)
268
- n = 1;
269
- if (m === -1)
270
- m = total;
271
- if (n < 1 || n > total)
272
- return { status: 416, error: `range start ${first} out of range (1..${total})` };
273
- if (m < 1 || m > total)
274
- return { status: 416, error: `range end ${last} out of range (1..${total})` };
275
- if (n > m)
276
- return { status: 416, error: `range start ${first} > end ${last}` };
277
- result = [...entries.slice(0, n - 1), ...bodyEntries, ...entries.slice(m)];
278
- }
279
- return { status: 200, result: JSON.stringify(Object.fromEntries(result), null, 2) };
280
- }
281
- static #applyJsonScalarEdit(source, marker, items) {
282
- // Scalar source is a length-1 list of itself. Only `<1>` replace
283
- // works cleanly; grow markers (<0>,<-1>) and ranges that imply
284
- // growth/delete would require type promotion (scalar → array),
285
- // which is the kind of implicit magic that bites later. Reject.
286
- const first = marker.marks[0];
287
- const last = marker.marks.length > 1 ? marker.marks[1] : null;
288
- if (last === null && first === 1) {
289
- if (items.length === 0)
290
- return { status: 200, result: "null" }; // delete the scalar
291
- if (items.length === 1)
292
- return { status: 200, result: JSON.stringify(items[0], null, 2) };
293
- return { status: 400, error: "scalar source: <1> body must produce 0 or 1 items (no implicit promotion to array)" };
294
- }
295
- if (last === -1 && first === 1) {
296
- // <1,-1> = whole content. Same constraints as <1> for scalars.
297
- if (items.length === 0)
298
- return { status: 200, result: "null" };
299
- if (items.length === 1)
300
- return { status: 200, result: JSON.stringify(items[0], null, 2) };
301
- return { status: 400, error: "scalar source: <1,-1> body must produce 0 or 1 items (no implicit promotion to array)" };
302
- }
303
- return { status: 400, error: "scalar JSON source: only <1> or <1,-1> markers supported (no implicit promotion to array via grow markers)" };
304
- }
305
- static jsonItemEdit(content, marker, body) {
306
- let parsed;
307
- try {
308
- parsed = JSON.parse(content);
309
- }
310
- catch (err) {
311
- return { status: 400, error: `malformed JSON source: ${err instanceof Error ? err.message : String(err)}` };
312
- }
313
- const bodyResult = Slicer.#itemsFromBody(body);
314
- if ("error" in bodyResult)
315
- return { status: 400, error: bodyResult.error };
316
- const items = bodyResult.items;
317
- // Empty-body sentinel insertion is a no-op (model accidentally
318
- // emitted no items at an insertion point). Single mark <0>/<-1> (#19).
319
- const isInsertSentinel = marker.marks.length === 1 && (marker.marks[0] === 0 || marker.marks[0] === -1);
320
- if (items.length === 0 && isInsertSentinel) {
321
- return { status: 200, result: content };
322
- }
323
- if (Array.isArray(parsed))
324
- return Slicer.#applyJsonArrayEdit(parsed, marker, items);
325
- if (parsed !== null && typeof parsed === "object")
326
- return Slicer.#applyJsonObjectEdit(parsed, marker, items);
327
- return Slicer.#applyJsonScalarEdit(parsed, marker, items);
328
- }
329
- // COPY-style raw line slice. Returns the selected lines verbatim (no line-
330
- // number prefix), trailing newline appended if any lines were selected.
327
+ // COPY-style raw slice. Returns the selected source bytes verbatim (no
328
+ // line-number prefix), including any separators owned by a whole-line
329
+ // selection.
331
330
  // Used for COPY/MOVE `<L>` per the service SPEC (COPY/MOVE source-range, symmetric
332
331
  // with READ but without the READ-output prefix that's a render concern,
333
332
  // not a data concern).
334
333
  static linesRaw(content, marker) {
335
- const { lines } = Slicer.#splitLines(content);
336
- const norm = Slicer.#normalize(marker, lines.length);
337
- if ("error" in norm)
338
- return { status: 416, error: norm.error };
339
- if (norm.kind !== "range")
340
- return { status: 200, text: "" };
341
- const selected = lines.slice(norm.start - 1, norm.end);
342
- const result = selected.length > 0 ? `${selected.join("\n")}\n` : "";
343
- return { status: 200, text: result };
334
+ const selected = Slicer.textReplacement(content, marker, "");
335
+ if ("error" in selected) {
336
+ return marker.marks.length !== 1 && marker.marks.length !== 2
337
+ ? Slicer.#regionFailure(selected.error, marker)
338
+ : Slicer.#rangeFailure(selected.error, Slicer.#extent(marker, TextCoordinates.logicalLines(content).length, "line"));
339
+ }
340
+ return {
341
+ status: 200,
342
+ text: content.slice(selected.start, selected.end),
343
+ startLine: selected.startLine,
344
+ ...(selected.normalization === undefined
345
+ ? {}
346
+ : { scopeNormalizations: [selected.normalization] }),
347
+ };
344
348
  }
345
349
  // EDIT applies body at the marker position:
346
350
  // <0> prepend body before line 1
347
351
  // <-1> append body after the last line
348
352
  // <N> replace line N with body
349
353
  // <N,M> replace lines N..M with body
350
- // <N.frac> insert body BETWEEN lines (after line floor(N.frac)); replaces
351
- // nothing — empty body is a no-op (#18).
354
+ // <SL,SC,EL,EC> replace an exact region; equal endpoints insert
352
355
  // <1,-1> whole content (replace everything); empty body clears.
353
356
  // Empty body with <N>/<N,M> deletes those lines.
354
357
  static lineMarkerEdit(content, marker, body) {
355
- const { lines, trailingNewline } = Slicer.#splitLines(content);
356
- const norm = Slicer.#normalize(marker, lines.length);
357
- if ("error" in norm)
358
- return { status: 416, error: norm.error };
359
- const bodyLines = Slicer.#splitLines(body).lines;
360
- let newLines;
361
- if (norm.kind === "before-first") {
362
- newLines = [...bodyLines, ...lines];
363
- }
364
- else if (norm.kind === "after-last") {
365
- newLines = [...lines, ...bodyLines];
366
- }
367
- else if (norm.kind === "insert-between") {
368
- // Insert after line floor(N.frac); both neighbours survive.
369
- newLines = [...lines.slice(0, norm.start), ...bodyLines, ...lines.slice(norm.start)];
370
- }
371
- else {
372
- newLines = [...lines.slice(0, norm.start - 1), ...bodyLines, ...lines.slice(norm.end)];
373
- }
374
- let result = newLines.join("\n");
375
- if (newLines.length > 0 && trailingNewline)
376
- result += "\n";
377
- return { status: 200, result };
358
+ return Slicer.lineMarkerEditBatch(content, [{ marker, body }]);
378
359
  }
379
- static #batchOrder(edits, total) {
380
- if (edits.length === 0)
381
- return { edits: [] };
382
- const regions = [];
360
+ static lineMarkerEditBatch(content, edits) {
361
+ const replacements = [];
383
362
  for (const edit of edits) {
384
- const norm = Slicer.#normalize(edit.marker, total);
385
- if ("error" in norm)
386
- return { status: 416, error: norm.error };
387
- const insertion = norm.kind !== "range";
388
- const whole = norm.kind === "range" && norm.start === 1 && norm.end === total;
389
- regions.push({ edit, start: norm.start, end: norm.end, insertion, whole });
390
- }
391
- if (regions.some(({ whole }) => whole) && regions.length > 1) {
392
- return { status: 409, error: "whole-resource replacement cannot coexist with another EDIT" };
393
- }
394
- for (let i = 0; i < regions.length; i += 1) {
395
- for (let j = i + 1; j < regions.length; j += 1) {
396
- const a = regions[i];
397
- const b = regions[j];
398
- if (a.insertion && b.insertion && a.start === b.start) {
399
- return { status: 409, error: `multiple EDITs target insertion boundary ${a.start}` };
400
- }
401
- if (!a.insertion && !b.insertion && a.start <= b.end && b.start <= a.end) {
402
- return { status: 409, error: `EDIT ranges ${a.start},${a.end} and ${b.start},${b.end} overlap` };
403
- }
404
- if (a.insertion !== b.insertion) {
405
- const insertionRegion = a.insertion ? a : b;
406
- const rangeRegion = a.insertion ? b : a;
407
- if (insertionRegion.start >= rangeRegion.start && insertionRegion.start < rangeRegion.end) {
408
- return { status: 409, error: `EDIT insertion boundary ${insertionRegion.start} falls inside range ${rangeRegion.start},${rangeRegion.end}` };
409
- }
363
+ const replacement = Slicer.textReplacement(content, edit.marker, edit.body);
364
+ if ("error" in replacement) {
365
+ return edit.marker.marks.length !== 1 && edit.marker.marks.length !== 2
366
+ ? Slicer.#regionFailure(replacement.error, edit.marker)
367
+ : Slicer.#rangeFailure(replacement.error, Slicer.#extent(edit.marker, TextCoordinates.logicalLines(content).length, "line"));
368
+ }
369
+ replacements.push({ ...replacement, marker: edit.marker });
370
+ }
371
+ if (replacements.length > 1 && replacements.some(({ start, end }) => start === 0 && end === content.length && start !== end)) {
372
+ return Slicer.#failure("overlapping-edits", 409, "A whole-resource replacement cannot coexist with another EDIT.", {}, {
373
+ stage: "mutation",
374
+ editCount: replacements.length,
375
+ recovery: "Submit the whole-resource replacement by itself.",
376
+ retryable: false,
377
+ });
378
+ }
379
+ for (let i = 0; i < replacements.length; i += 1) {
380
+ for (let j = i + 1; j < replacements.length; j += 1) {
381
+ const a = replacements[i];
382
+ const b = replacements[j];
383
+ const aInsertion = a.start === a.end;
384
+ const bInsertion = b.start === b.end;
385
+ const conflicts = aInsertion && bInsertion
386
+ ? a.start === b.start
387
+ : aInsertion
388
+ ? a.start >= b.start && a.start < b.end
389
+ : bInsertion
390
+ ? b.start >= a.start && b.start < a.end
391
+ : a.start < b.end && b.start < a.end;
392
+ if (conflicts) {
393
+ const lineRanges = [a.marker, b.marker].every(({ marks }) => marks.length === 1 || marks.length === 2)
394
+ ? {
395
+ conflictingRanges: [a.marker, b.marker].map(({ marks }) => ({
396
+ first: marks[0],
397
+ last: marks[1] ?? marks[0],
398
+ })),
399
+ }
400
+ : {};
401
+ return Slicer.#failure("overlapping-edits", 409, `EDIT regions <${a.marker.marks.join(",")}> and <${b.marker.marks.join(",")}> overlap.`, {}, {
402
+ stage: "mutation",
403
+ conflictingRegions: [a.marker.marks, b.marker.marks],
404
+ ...lineRanges,
405
+ recovery: "Submit non-overlapping EDIT regions.",
406
+ retryable: false,
407
+ });
410
408
  }
411
409
  }
412
410
  }
413
- return {
414
- edits: regions
415
- .sort((a, b) => b.start - a.start || Number(a.insertion) - Number(b.insertion))
416
- .map(({ edit }) => edit),
417
- };
418
- }
419
- static lineMarkerEditBatch(content, edits) {
420
- const total = Slicer.#splitLines(content).lines.length;
421
- const ordered = Slicer.#batchOrder(edits, total);
422
- if (ordered.error !== undefined)
423
- return { status: ordered.status ?? 409, error: ordered.error };
424
411
  let result = content;
425
- for (const edit of ordered.edits ?? []) {
426
- const applied = Slicer.lineMarkerEdit(result, edit.marker, edit.body);
427
- if (applied.status !== 200)
428
- return applied;
429
- result = applied.result ?? "";
412
+ for (const replacement of replacements.toSorted((a, b) => b.start - a.start || b.end - a.end)) {
413
+ result = `${result.slice(0, replacement.start)}${replacement.body}${result.slice(replacement.end)}`;
430
414
  }
431
- return { status: 200, result };
432
- }
433
- static jsonItemEditBatch(content, edits) {
434
- let parsed;
435
- try {
436
- parsed = JSON.parse(content);
437
- }
438
- catch (err) {
439
- return { status: 400, error: `malformed JSON source: ${err instanceof Error ? err.message : String(err)}` };
440
- }
441
- const total = Slicer.#jsonValueToItems(parsed).length;
442
- const ordered = Slicer.#batchOrder(edits, total);
443
- if (ordered.error !== undefined)
444
- return { status: ordered.status ?? 409, error: ordered.error };
445
- let result = content;
446
- for (const edit of ordered.edits ?? []) {
447
- const applied = Slicer.jsonItemEdit(result, edit.marker, edit.body);
448
- if (applied.status !== 200)
449
- return applied;
450
- result = applied.result ?? "";
451
- }
452
- return { status: 200, result };
415
+ const scopeNormalizations = replacements.flatMap(({ normalization }) => normalization === undefined ? [] : [normalization]);
416
+ return {
417
+ status: 200,
418
+ result,
419
+ ...(scopeNormalizations.length === 0 ? {} : { scopeNormalizations }),
420
+ };
453
421
  }
454
422
  }
455
423
  //# sourceMappingURL=Slicer.js.map