@plurnk/plurnk-schemes 1.3.12 → 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.
- package/README.md +25 -14
- package/SPEC.md +311 -75
- package/dist/DefaultRead.d.ts +8 -4
- package/dist/DefaultRead.d.ts.map +1 -1
- package/dist/DefaultRead.js +37 -13
- package/dist/DefaultRead.js.map +1 -1
- package/dist/Manifest.d.ts +6 -0
- package/dist/Manifest.d.ts.map +1 -0
- package/dist/Manifest.js +115 -0
- package/dist/Manifest.js.map +1 -0
- package/dist/Matcher.d.ts +5 -6
- package/dist/Matcher.d.ts.map +1 -1
- package/dist/Matcher.js +51 -59
- package/dist/Matcher.js.map +1 -1
- package/dist/MimetypeClassifier.d.ts +1 -1
- package/dist/MimetypeClassifier.d.ts.map +1 -1
- package/dist/MimetypeClassifier.js +11 -15
- package/dist/MimetypeClassifier.js.map +1 -1
- package/dist/NetworkAddress.d.ts +13 -0
- package/dist/NetworkAddress.d.ts.map +1 -0
- package/dist/NetworkAddress.js +43 -0
- package/dist/NetworkAddress.js.map +1 -0
- package/dist/OutputScheme.d.ts.map +1 -1
- package/dist/OutputScheme.js +5 -6
- package/dist/OutputScheme.js.map +1 -1
- package/dist/PacketSections.d.ts +6 -0
- package/dist/PacketSections.d.ts.map +1 -0
- package/dist/PacketSections.js +42 -0
- package/dist/PacketSections.js.map +1 -0
- package/dist/PathMimetype.js +9 -9
- package/dist/PathMimetype.js.map +1 -1
- package/dist/Results.d.ts +29 -8
- package/dist/Results.d.ts.map +1 -1
- package/dist/Results.js +102 -44
- package/dist/Results.js.map +1 -1
- package/dist/SchemeDiscovery.d.ts +2 -0
- package/dist/SchemeDiscovery.d.ts.map +1 -1
- package/dist/SchemeDiscovery.js +40 -42
- package/dist/SchemeDiscovery.js.map +1 -1
- package/dist/SchemeResolver.d.ts.map +1 -1
- package/dist/SchemeResolver.js +4 -5
- package/dist/SchemeResolver.js.map +1 -1
- package/dist/Slicer.d.ts +36 -14
- package/dist/Slicer.d.ts.map +1 -1
- package/dist/Slicer.js +330 -362
- package/dist/Slicer.js.map +1 -1
- package/dist/Summarize.js +3 -3
- package/dist/Summarize.js.map +1 -1
- package/dist/ctx.d.ts +60 -60
- package/dist/ctx.d.ts.map +1 -1
- package/dist/edit-receipt.d.ts +39 -0
- package/dist/edit-receipt.d.ts.map +1 -0
- package/dist/edit-receipt.js +2 -0
- package/dist/edit-receipt.js.map +1 -0
- package/dist/handler.d.ts +7 -8
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +4 -2
- package/dist/handler.js.map +1 -1
- package/dist/index.d.ts +13 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/packet.d.ts +2 -3
- package/dist/packet.d.ts.map +1 -1
- package/dist/packet.js +2 -20
- package/dist/packet.js.map +1 -1
- package/dist/types.d.ts +3 -11
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -7
- package/dist/types.js.map +1 -1
- 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
|
-
// <
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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 #
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
30
|
-
//
|
|
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 {
|
|
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: `
|
|
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: `
|
|
213
|
+
return { error: `Range start ${first} is outside the available line range 1..${totalLines}.` };
|
|
70
214
|
if (m < 1 || m > totalLines)
|
|
71
|
-
return { error: `
|
|
215
|
+
return { error: `Range end ${last} is outside the available line range 1..${totalLines}.` };
|
|
72
216
|
if (n > m)
|
|
73
|
-
return { error: `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
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,
|
|
307
|
+
return { status: 200, items: [] };
|
|
132
308
|
if (first > 0 && first <= total)
|
|
133
|
-
return { status: 200,
|
|
134
|
-
return
|
|
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,
|
|
141
|
-
return
|
|
314
|
+
return { status: 200, items: [] };
|
|
315
|
+
return Slicer.#rangeFailure(`Result range ${first},${last} cannot select from an empty result set.`, extent);
|
|
142
316
|
}
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
|
155
|
-
return { status: 200,
|
|
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
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
// <
|
|
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
|
-
|
|
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
|
|
380
|
-
|
|
381
|
-
return { edits: [] };
|
|
382
|
-
const regions = [];
|
|
360
|
+
static lineMarkerEditBatch(content, edits) {
|
|
361
|
+
const replacements = [];
|
|
383
362
|
for (const edit of edits) {
|
|
384
|
-
const
|
|
385
|
-
if ("error" in
|
|
386
|
-
return
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
426
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|