@plurnk/plurnk-schemes 1.3.12 → 1.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.
Files changed (71) hide show
  1. package/README.md +43 -17
  2. package/SPEC.md +368 -86
  3. package/dist/Manifest.d.ts +6 -0
  4. package/dist/Manifest.d.ts.map +1 -0
  5. package/dist/Manifest.js +115 -0
  6. package/dist/Manifest.js.map +1 -0
  7. package/dist/Matcher.d.ts +5 -6
  8. package/dist/Matcher.d.ts.map +1 -1
  9. package/dist/Matcher.js +51 -59
  10. package/dist/Matcher.js.map +1 -1
  11. package/dist/MimetypeClassifier.d.ts +1 -1
  12. package/dist/MimetypeClassifier.d.ts.map +1 -1
  13. package/dist/MimetypeClassifier.js +11 -15
  14. package/dist/MimetypeClassifier.js.map +1 -1
  15. package/dist/NetworkAddress.d.ts +13 -0
  16. package/dist/NetworkAddress.d.ts.map +1 -0
  17. package/dist/NetworkAddress.js +43 -0
  18. package/dist/NetworkAddress.js.map +1 -0
  19. package/dist/OutputScheme.d.ts.map +1 -1
  20. package/dist/OutputScheme.js +5 -6
  21. package/dist/OutputScheme.js.map +1 -1
  22. package/dist/PacketSections.d.ts +6 -0
  23. package/dist/PacketSections.d.ts.map +1 -0
  24. package/dist/PacketSections.js +42 -0
  25. package/dist/PacketSections.js.map +1 -0
  26. package/dist/PathMimetype.js +9 -9
  27. package/dist/PathMimetype.js.map +1 -1
  28. package/dist/Results.d.ts +42 -8
  29. package/dist/Results.d.ts.map +1 -1
  30. package/dist/Results.js +132 -44
  31. package/dist/Results.js.map +1 -1
  32. package/dist/SchemeDiscovery.d.ts +2 -0
  33. package/dist/SchemeDiscovery.d.ts.map +1 -1
  34. package/dist/SchemeDiscovery.js +40 -42
  35. package/dist/SchemeDiscovery.js.map +1 -1
  36. package/dist/SchemeResolver.d.ts.map +1 -1
  37. package/dist/SchemeResolver.js +4 -5
  38. package/dist/SchemeResolver.js.map +1 -1
  39. package/dist/Slicer.d.ts +27 -14
  40. package/dist/Slicer.d.ts.map +1 -1
  41. package/dist/Slicer.js +382 -374
  42. package/dist/Slicer.js.map +1 -1
  43. package/dist/Summarize.js +3 -3
  44. package/dist/Summarize.js.map +1 -1
  45. package/dist/ctx.d.ts +70 -67
  46. package/dist/ctx.d.ts.map +1 -1
  47. package/dist/edit-receipt.d.ts +39 -0
  48. package/dist/edit-receipt.d.ts.map +1 -0
  49. package/dist/edit-receipt.js +2 -0
  50. package/dist/edit-receipt.js.map +1 -0
  51. package/dist/handler.d.ts +13 -10
  52. package/dist/handler.d.ts.map +1 -1
  53. package/dist/handler.js +7 -5
  54. package/dist/handler.js.map +1 -1
  55. package/dist/index.d.ts +14 -8
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +6 -1
  58. package/dist/index.js.map +1 -1
  59. package/dist/packet.d.ts +2 -3
  60. package/dist/packet.d.ts.map +1 -1
  61. package/dist/packet.js +2 -20
  62. package/dist/packet.js.map +1 -1
  63. package/dist/types.d.ts +3 -11
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/types.js +1 -7
  66. package/dist/types.js.map +1 -1
  67. package/package.json +10 -8
  68. package/dist/DefaultRead.d.ts +0 -11
  69. package/dist/DefaultRead.d.ts.map +0 -1
  70. package/dist/DefaultRead.js +0 -27
  71. package/dist/DefaultRead.js.map +0 -1
package/dist/Slicer.js CHANGED
@@ -5,49 +5,201 @@
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
+ const first = marker.marks[0];
43
+ return {
44
+ unit,
45
+ total,
46
+ requested: [first, marker.marks[1] ?? first],
47
+ };
48
+ }
49
+ static #projectedExtent(extent, first, last) {
50
+ return first === null || last === null
51
+ ? extent
52
+ : { ...extent, returned: [first, last] };
53
+ }
54
+ static coversAvailable(range) {
55
+ return range.total === 0
56
+ || (range.returned?.[0] === 1 && range.returned[1] === range.total);
57
+ }
58
+ static #exactTextReplacement(content, marker, body) {
59
+ const [startLine, startColumn, endLine, rawEndColumn] = marker.marks;
60
+ if (![startLine, startColumn, endLine, rawEndColumn].every(Number.isSafeInteger)) {
61
+ return { error: "An exact text region requires four integer coordinates." };
62
+ }
63
+ const lines = TextCoordinates.lines(content);
64
+ if (startLine < 1 || startLine > lines.length) {
65
+ return { error: `Start line ${startLine} is outside the available line range 1..${lines.length}.` };
66
+ }
67
+ if (endLine < 1 || endLine > lines.length) {
68
+ return { error: `End line ${endLine} is outside the available line range 1..${lines.length}.` };
69
+ }
70
+ // A harmless end-column overshoot clamps to the line's end — the model
71
+ // asked for "through the end" and the line is shorter than it guessed.
72
+ const endLineData = lines[endLine - 1];
73
+ const maxEndColumn = [...content.slice(endLineData.start, endLineData.contentEnd)].length + 1;
74
+ const endColumn = Math.min(rawEndColumn, maxEndColumn);
75
+ let start;
76
+ let end;
77
+ try {
78
+ start = TextCoordinates.offsetAtPosition(content, startLine, startColumn);
79
+ end = TextCoordinates.offsetAtPosition(content, endLine, endColumn);
80
+ }
81
+ catch (cause) {
82
+ return { error: cause instanceof Error ? cause.message : String(cause) };
83
+ }
84
+ if (end < start) {
85
+ return {
86
+ error: `Exact region ${startLine},${startColumn},${endLine},${rawEndColumn} ends before it starts.`,
87
+ };
88
+ }
89
+ return { start, end, body, startLine, endLine };
90
+ }
91
+ static #preferredSeparator(content, lines) {
92
+ return lines.find(({ separator }) => separator.length > 0)?.separator
93
+ ?? (content.includes("\r\n") ? "\r\n" : content.includes("\r") ? "\r" : "\n");
94
+ }
95
+ static #lineTextReplacement(content, marker, body) {
96
+ const lines = TextCoordinates.logicalLines(content);
97
+ const norm = Slicer.#normalize(marker, lines.length);
98
+ if ("error" in norm)
99
+ return norm;
100
+ const separator = Slicer.#preferredSeparator(content, lines);
101
+ if (norm.kind === "before-first") {
102
+ const inserted = body.length > 0 && content.length > 0 && !/[\r\n]$/.test(body)
103
+ ? `${body}${separator}`
104
+ : body;
105
+ return { start: 0, end: 0, body: inserted, startLine: 1, endLine: 1 };
106
+ }
107
+ if (norm.kind === "after-last") {
108
+ const prefix = body.length > 0 && content.length > 0 && !/[\r\n]$/.test(content)
109
+ ? separator
110
+ : "";
111
+ const suffix = body.length > 0 && /[\r\n]$/.test(content) && !/[\r\n]$/.test(body)
112
+ ? separator
113
+ : "";
114
+ return {
115
+ start: content.length,
116
+ end: content.length,
117
+ body: `${prefix}${body}${suffix}`,
118
+ startLine: Math.max(lines.length, 1),
119
+ endLine: Math.max(lines.length, 1),
120
+ };
121
+ }
122
+ const first = lines[norm.start - 1];
123
+ const last = lines[norm.end - 1];
124
+ if (first === undefined || last === undefined) {
125
+ if (lines.length === 0 && norm.start === 1 && norm.end === 0) {
126
+ return { start: 0, end: 0, body, startLine: 1, endLine: 1 };
127
+ }
128
+ return { error: `Line range ${norm.start},${norm.end} cannot be resolved.` };
129
+ }
130
+ const inserted = body.length > 0 && last.separator.length > 0 && !/[\r\n]$/.test(body)
131
+ ? `${body}${last.separator}`
132
+ : body;
133
+ return {
134
+ start: first.start,
135
+ end: last.end,
136
+ body: inserted,
137
+ startLine: norm.start,
138
+ endLine: norm.end,
139
+ };
140
+ }
141
+ static textReplacement(content, marker, body) {
142
+ if (marker.marks.length === 3) {
143
+ const expanded = Slicer.#expandRegion3(content, marker);
144
+ if ("error" in expanded)
145
+ return expanded;
146
+ const replacement = Slicer.#exactTextReplacement(content, expanded.marker, body);
147
+ return "error" in replacement
148
+ ? replacement
149
+ : { ...replacement, normalization: expanded.normalization };
150
+ }
151
+ if (marker.marks.length === 4) {
152
+ return Slicer.#exactTextReplacement(content, marker, body);
153
+ }
154
+ if (marker.marks.length === 1 || marker.marks.length === 2) {
155
+ return Slicer.#lineTextReplacement(content, marker, body);
156
+ }
157
+ return {
158
+ error: `Text regions require one, two, or four coordinates; received ${marker.marks.length}.`,
159
+ };
160
+ }
161
+ static #expandRegion3(content, marker) {
162
+ const [startLine, startColumn, endLine] = marker.marks;
163
+ if (![startLine, startColumn, endLine].every(Number.isSafeInteger)) {
164
+ return { error: "A tolerated three-coordinate text region requires integer coordinates." };
165
+ }
166
+ const lines = TextCoordinates.lines(content);
167
+ const last = lines[endLine - 1];
168
+ if (endLine < 1 || last === undefined) {
169
+ return { error: `End line ${endLine} is outside the available line range 1..${lines.length}.` };
170
+ }
171
+ const endColumn = [...content.slice(last.start, last.contentEnd)].length + 1;
172
+ const requested = [startLine, startColumn, endLine];
173
+ const canonical = [startLine, startColumn, endLine, endColumn];
174
+ return {
175
+ marker: { ...marker, marks: [...canonical] },
176
+ normalization: { requested, canonical },
177
+ };
27
178
  }
28
179
  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
180
+ // {§slicer-text-algebra} The parser carries raw `marks: [number, ...]`;
181
+ // this owner assigns roles: marks[0] = first/position, marks[1] = last
31
182
  // (range end). A single mark is a position/sentinel; two is a range.
183
+ if (marker.marks.length !== 1 && marker.marks.length !== 2) {
184
+ return { error: `A positional range requires one or two coordinates; received ${marker.marks.length}.` };
185
+ }
32
186
  const first = marker.marks[0];
33
187
  const last = marker.marks.length > 1 ? marker.marks[1] : null;
188
+ if (!Number.isInteger(first) || (last !== null && !Number.isInteger(last))) {
189
+ return { error: "Whole-line scopes require integer coordinates." };
190
+ }
34
191
  if (last === null) {
35
192
  if (first === 0)
36
193
  return { kind: "before-first", start: 0, end: 0 };
37
194
  if (first === -1)
38
195
  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
196
  if (first > 0 && first <= totalLines)
49
197
  return { kind: "range", start: first, end: first };
50
- return { error: `line ${first} out of range (1..${totalLines})` };
198
+ return {
199
+ error: totalLines === 0
200
+ ? `Line ${first} cannot select from empty content.`
201
+ : `Line ${first} is outside the available line range 1..${totalLines}.`,
202
+ };
51
203
  }
52
204
  // Whole-content `<1,-1>` (and its `<0,-1>` alias) is valid even on
53
205
  // empty content — it selects the entire, possibly-empty range. Without
@@ -55,401 +207,257 @@ export default class Slicer {
55
207
  // entry would 416. start>end here yields an empty slice for READ and a
56
208
  // full replacement for EDIT.
57
209
  if (totalLines === 0) {
58
- if ((first === 0 || first === 1) && last === -1)
210
+ if ((first === 0 || first === 1) && (last === -1 || last > 0)) {
59
211
  return { kind: "range", start: 1, end: 0 };
60
- return { error: `range ${first},${last} out of range (content is empty)` };
212
+ }
213
+ return { error: `Range ${first},${last} cannot select from empty content.` };
61
214
  }
62
215
  let n = first;
63
216
  let m = last;
64
217
  if (n === 0)
65
218
  n = 1;
66
- if (m === -1)
219
+ if (m === -1 || m > totalLines)
67
220
  m = totalLines;
68
221
  if (n < 1 || n > totalLines)
69
- return { error: `range start ${first} out of range (1..${totalLines})` };
70
- if (m < 1 || m > totalLines)
71
- return { error: `range end ${last} out of range (1..${totalLines})` };
222
+ return { error: `Range start ${first} is outside the available line range 1..${totalLines}.` };
223
+ if (m < 1)
224
+ return { error: `Range end ${last} is outside the available line range 1..${totalLines}.` };
72
225
  if (n > m)
73
- return { error: `range start ${first} > end ${last}` };
226
+ return { error: `Range start ${first} exceeds end ${last}.` };
74
227
  return { kind: "range", start: n, end: m };
75
228
  }
76
- // READ a line range. Returns the raw selected lines (no `N:\t` prefix)
229
+ // READ a line range. Returns the raw selected lines (no `N:` prefix)
77
230
  // 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
231
+ // layer adds `N:` per plurnk.md starting from `startLine` - keeps numbering as a
80
232
  // presentation concern, prevents double-prefixing when the same content
81
233
  // passes through the log render.
82
234
  //
83
235
  // Sentinel positions <0> and <-1> select no content (they're insertion
84
236
  // points, not lines) → status 200 with empty text.
85
237
  static lines(content, marker) {
86
- const { lines } = Slicer.#splitLines(content);
238
+ const requestedMarker = marker;
239
+ let normalization;
240
+ if (marker.marks.length === 3) {
241
+ const expanded = Slicer.#expandRegion3(content, marker);
242
+ if ("error" in expanded)
243
+ return Slicer.#regionFailure(expanded.error, requestedMarker);
244
+ marker = expanded.marker;
245
+ normalization = expanded.normalization;
246
+ }
247
+ if (marker.marks.length === 4) {
248
+ const selected = Slicer.#exactTextReplacement(content, marker, "");
249
+ if ("error" in selected) {
250
+ return Slicer.#regionFailure(selected.error, requestedMarker);
251
+ }
252
+ const region = TextCoordinates.regionFromOffsets(content, selected.start, selected.end);
253
+ if (region === null) {
254
+ throw new Error("Slicer resolved an exact replacement with no addressable TextRegion.");
255
+ }
256
+ return {
257
+ status: 200,
258
+ text: content.slice(selected.start, selected.end),
259
+ startLine: selected.startLine,
260
+ region,
261
+ ...(normalization === undefined ? {} : { scopeNormalizations: [normalization] }),
262
+ };
263
+ }
264
+ if (marker.marks.length !== 1 && marker.marks.length !== 2) {
265
+ return Slicer.#regionFailure(`Text regions require one, two, or four coordinates; received ${marker.marks.length}.`, marker);
266
+ }
267
+ const lines = TextCoordinates.logicalLines(content);
87
268
  const norm = Slicer.#normalize(marker, lines.length);
88
- if ("error" in norm)
89
- return { status: 416, error: norm.error };
90
- if (norm.kind !== "range")
91
- 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);
121
- }
122
- catch (err) {
123
- return { status: 400, error: `malformed JSON: ${err instanceof Error ? err.message : String(err)}` };
269
+ if ("error" in norm) {
270
+ return Slicer.#rangeFailure(norm.error, Slicer.#extent(marker, lines.length, "line"));
271
+ }
272
+ const extent = Slicer.#extent(marker, lines.length, "line");
273
+ if (norm.kind !== "range") {
274
+ return {
275
+ status: 200,
276
+ text: "",
277
+ startLine: undefined,
278
+ range: Slicer.#projectedExtent(extent, null, null),
279
+ };
280
+ }
281
+ if (lines.length === 0) {
282
+ const region = TextCoordinates.regionFromOffsets(content, 0, 0);
283
+ if (region === null) {
284
+ throw new Error("Slicer could not address an empty text resource.");
285
+ }
286
+ return {
287
+ status: 200,
288
+ text: "",
289
+ startLine: 1,
290
+ region,
291
+ range: Slicer.#projectedExtent(extent, null, null),
292
+ };
293
+ }
294
+ const selected = lines
295
+ .slice(norm.start - 1, norm.end)
296
+ .map(({ start, contentEnd }) => content.slice(start, contentEnd));
297
+ const region = TextCoordinates.lineRegion(content, norm.start, norm.end);
298
+ if (region === null) {
299
+ throw new Error("Slicer resolved a whole-line selection with no addressable TextRegion.");
124
300
  }
125
- const items = Slicer.#jsonValueToItems(parsed);
301
+ return {
302
+ status: 200,
303
+ text: selected.join("\n"),
304
+ startLine: norm.start,
305
+ region,
306
+ range: Slicer.#projectedExtent(extent, norm.start, norm.end),
307
+ };
308
+ }
309
+ // `<L>` over an ordered result set uses the same positional contract as
310
+ // READ slicing. The returned extent lets a scheme put exact recovery facts
311
+ // in RFC 9457 Problem Details instead of emitting a bare 416.
312
+ static page(items, marker, options = {}) {
126
313
  const total = items.length;
127
- const first = marker.marks[0];
128
- const last = marker.marks.length > 1 ? marker.marks[1] : null;
129
- if (last === null) {
130
- if (first === 0 || first === -1)
131
- return { status: 200, body: "[]" };
132
- 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})` };
135
- }
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).
314
+ const extent = Slicer.#extent(marker, total, options.unit ?? "result");
315
+ if (marker.marks.length !== 1 && marker.marks.length !== 2) {
316
+ 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 }, {
317
+ range: extent,
318
+ requestedPositions: marker.marks,
319
+ stage: "projection",
320
+ recovery: "Use <N> or <N,M> to select results.",
321
+ retryable: false,
322
+ });
323
+ }
324
+ const [first, last] = extent.requested;
325
+ if (!Number.isInteger(first) || !Number.isInteger(last)) {
326
+ return Slicer.#rangeFailure(`Result ranges require integer positions; the result set contains ${total} item(s).`, extent);
327
+ }
328
+ if (marker.marks.length === 1) {
329
+ if (first === 0 || first === -1) {
330
+ return { status: 200, items: [], range: Slicer.#projectedExtent(extent, null, null) };
331
+ }
332
+ if (total === 0 && options.allowEmpty === true && first > 0) {
333
+ return { status: 200, items: [], range: Slicer.#projectedExtent(extent, null, null) };
334
+ }
335
+ if (first > 0 && first <= total) {
336
+ return {
337
+ status: 200,
338
+ items: [items[first - 1]],
339
+ range: Slicer.#projectedExtent(extent, first, first),
340
+ };
341
+ }
342
+ return Slicer.#rangeFailure(`Result ${first} is out of range; available positions are 1..${total}.`, extent);
343
+ }
138
344
  if (total === 0) {
139
- 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)` };
345
+ if (options.allowEmpty === true && first > 0 && (last === -1 || last >= first)) {
346
+ return { status: 200, items: [], range: Slicer.#projectedExtent(extent, null, null) };
347
+ }
348
+ if ((first === 0 || first === 1) && last === -1) {
349
+ return { status: 200, items: [], range: Slicer.#projectedExtent(extent, null, null) };
350
+ }
351
+ return Slicer.#rangeFailure(`Result range ${first},${last} cannot select from an empty result set.`, extent);
142
352
  }
143
- let n = first;
144
- let m = last;
145
- if (n === 0)
146
- n = 1;
147
- if (m === -1)
148
- m = total;
353
+ const n = first === 0 ? 1 : first;
354
+ const m = last === -1 ? total : Math.min(last, total);
149
355
  if (n < 1 || n > total)
150
- return { status: 416, error: `range start ${first} out of range (1..${total})` };
151
- if (m < 1 || m > total)
152
- return { status: 416, error: `range end ${last} out of range (1..${total})` };
356
+ return Slicer.#rangeFailure(`Result range start ${first} is out of range; available positions are 1..${total}.`, extent);
357
+ if (m < 1)
358
+ return Slicer.#rangeFailure(`Result range end ${last} is out of range; available positions are 1..${total}.`, extent);
153
359
  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) };
156
- }
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);
360
+ return Slicer.#rangeFailure(`Result range start ${first} exceeds end ${last}.`, extent);
361
+ return {
362
+ status: 200,
363
+ items: items.slice(n - 1, m),
364
+ range: Slicer.#projectedExtent(extent, n, m),
365
+ };
328
366
  }
329
- // COPY-style raw line slice. Returns the selected lines verbatim (no line-
330
- // number prefix), trailing newline appended if any lines were selected.
367
+ // COPY-style raw slice. Returns the selected source bytes verbatim (no
368
+ // line-number prefix), including any separators owned by a whole-line
369
+ // selection.
331
370
  // Used for COPY/MOVE `<L>` per the service SPEC (COPY/MOVE source-range, symmetric
332
371
  // with READ but without the READ-output prefix that's a render concern,
333
372
  // not a data concern).
334
373
  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 };
374
+ const selected = Slicer.textReplacement(content, marker, "");
375
+ if ("error" in selected) {
376
+ return marker.marks.length !== 1 && marker.marks.length !== 2
377
+ ? Slicer.#regionFailure(selected.error, marker)
378
+ : Slicer.#rangeFailure(selected.error, Slicer.#extent(marker, TextCoordinates.logicalLines(content).length, "line"));
379
+ }
380
+ return {
381
+ status: 200,
382
+ text: content.slice(selected.start, selected.end),
383
+ startLine: selected.startLine,
384
+ ...(selected.normalization === undefined
385
+ ? {}
386
+ : { scopeNormalizations: [selected.normalization] }),
387
+ };
344
388
  }
345
389
  // EDIT applies body at the marker position:
346
390
  // <0> prepend body before line 1
347
391
  // <-1> append body after the last line
348
392
  // <N> replace line N with body
349
393
  // <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).
394
+ // <SL,SC,EL,EC> replace an exact region; equal endpoints insert
352
395
  // <1,-1> whole content (replace everything); empty body clears.
353
396
  // Empty body with <N>/<N,M> deletes those lines.
354
397
  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 };
398
+ return Slicer.lineMarkerEditBatch(content, [{ marker, body }]);
378
399
  }
379
- static #batchOrder(edits, total) {
380
- if (edits.length === 0)
381
- return { edits: [] };
382
- const regions = [];
400
+ static lineMarkerEditBatch(content, edits) {
401
+ const replacements = [];
383
402
  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
- }
403
+ const replacement = Slicer.textReplacement(content, edit.marker, edit.body);
404
+ if ("error" in replacement) {
405
+ return edit.marker.marks.length !== 1 && edit.marker.marks.length !== 2
406
+ ? Slicer.#regionFailure(replacement.error, edit.marker)
407
+ : Slicer.#rangeFailure(replacement.error, Slicer.#extent(edit.marker, TextCoordinates.logicalLines(content).length, "line"));
408
+ }
409
+ replacements.push({ ...replacement, marker: edit.marker });
410
+ }
411
+ if (replacements.length > 1 && replacements.some(({ start, end }) => start === 0 && end === content.length && start !== end)) {
412
+ return Slicer.#failure("overlapping-edits", 409, "A whole-resource replacement cannot coexist with another EDIT.", {}, {
413
+ stage: "mutation",
414
+ editCount: replacements.length,
415
+ recovery: "Submit the whole-resource replacement by itself.",
416
+ retryable: false,
417
+ });
418
+ }
419
+ for (let i = 0; i < replacements.length; i += 1) {
420
+ for (let j = i + 1; j < replacements.length; j += 1) {
421
+ const a = replacements[i];
422
+ const b = replacements[j];
423
+ const aInsertion = a.start === a.end;
424
+ const bInsertion = b.start === b.end;
425
+ const conflicts = aInsertion && bInsertion
426
+ ? a.start === b.start
427
+ : aInsertion
428
+ ? a.start >= b.start && a.start < b.end
429
+ : bInsertion
430
+ ? b.start >= a.start && b.start < a.end
431
+ : a.start < b.end && b.start < a.end;
432
+ if (conflicts) {
433
+ const lineRanges = [a.marker, b.marker].every(({ marks }) => marks.length === 1 || marks.length === 2)
434
+ ? {
435
+ conflictingRanges: [a.marker, b.marker].map(({ marks }) => ({
436
+ first: marks[0],
437
+ last: marks[1] ?? marks[0],
438
+ })),
439
+ }
440
+ : {};
441
+ return Slicer.#failure("overlapping-edits", 409, `EDIT regions <${a.marker.marks.join(",")}> and <${b.marker.marks.join(",")}> overlap.`, {}, {
442
+ stage: "mutation",
443
+ conflictingRegions: [a.marker.marks, b.marker.marks],
444
+ ...lineRanges,
445
+ recovery: "Submit non-overlapping EDIT regions.",
446
+ retryable: false,
447
+ });
410
448
  }
411
449
  }
412
450
  }
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
451
  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 ?? "";
452
+ for (const replacement of replacements.toSorted((a, b) => b.start - a.start || b.end - a.end)) {
453
+ result = `${result.slice(0, replacement.start)}${replacement.body}${result.slice(replacement.end)}`;
430
454
  }
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 };
455
+ const scopeNormalizations = replacements.flatMap(({ normalization }) => normalization === undefined ? [] : [normalization]);
456
+ return {
457
+ status: 200,
458
+ result,
459
+ ...(scopeNormalizations.length === 0 ? {} : { scopeNormalizations }),
460
+ };
453
461
  }
454
462
  }
455
463
  //# sourceMappingURL=Slicer.js.map