@cueloop/schema 0.1.0-alpha.9 → 0.1.0-alpha.91

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.
@@ -1,5 +1,15 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { cutBlock, restoreBlock, restoreLine, sourceChunk } from "./working-copy";
2
+ import {
3
+ applyTextCuts,
4
+ blockCutSourceRange,
5
+ cutBlock,
6
+ cutTextRange,
7
+ mergeTextCut,
8
+ restoreTextCut,
9
+ restoreBlock,
10
+ restoreLine,
11
+ sourceChunk,
12
+ } from "./working-copy";
3
13
  import { parseBlocks, type Block } from "./markdown";
4
14
 
5
15
  const BASE = `# Plan
@@ -18,92 +28,267 @@ const y = 2;
18
28
  \`\`\`
19
29
  `;
20
30
 
21
- function block(md: string, text: string): Block {
22
- return parseBlocks(md).find((b) => b.text.includes(text))!;
31
+ function block(markdown: string, text: string): Block {
32
+ return parseBlocks(markdown).find((block) => block.text.includes(text))!;
23
33
  }
24
34
 
25
35
  describe("sourceChunk", () => {
26
36
  test("returns the exact source lines a block occupies", () => {
27
37
  expect(sourceChunk(BASE, block(BASE, "first item"))).toBe("- first item");
28
- expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe("A paragraph that\nspans two lines.");
38
+ expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe(
39
+ "A paragraph that\nspans two lines.",
40
+ );
29
41
  });
30
42
 
31
43
  test("code block chunk includes both fence lines", () => {
32
- expect(sourceChunk(BASE, block(BASE, "const x"))).toBe("```ts\nconst x = 1;\nconst y = 2;\n```");
44
+ expect(sourceChunk(BASE, block(BASE, "const x"))).toBe(
45
+ "```ts\nconst x = 1;\nconst y = 2;\n```",
46
+ );
33
47
  });
34
48
  });
35
49
 
36
50
  describe("cutBlock", () => {
37
51
  test("removes the block lines and the blank line before it", () => {
52
+ // Act
38
53
  const cut = cutBlock(BASE, block(BASE, "spans two lines"));
54
+
55
+ // Assert
39
56
  expect(cut).not.toContain("spans two lines");
40
57
  expect(cut).toContain("## Context\n\n- first item");
41
58
  });
42
59
 
43
60
  test("cut at the start of the document leaves the leading blank line", () => {
61
+ // Act
44
62
  const cut = cutBlock(BASE, block(BASE, "Plan"));
63
+
64
+ // Assert
45
65
  expect(cut).not.toContain("# Plan");
46
66
  expect(cut.trimStart().startsWith("## Context")).toBe(true);
47
67
  });
48
68
 
49
69
  test("cut at the end of the document keeps the rest intact", () => {
70
+ // Act
50
71
  const cut = cutBlock(BASE, block(BASE, "const x"));
72
+
73
+ // Assert
51
74
  expect(cut).not.toContain("const x");
52
75
  expect(cut).toContain("- second item");
53
76
  });
77
+
78
+ test.each(["Plan", "spans two lines", "first item", "const x"])(
79
+ "reports the exact source interval cut from %s",
80
+ (text) => {
81
+ const target = block(BASE, text);
82
+ const range = blockCutSourceRange(BASE, target);
83
+
84
+ expect(BASE.slice(0, range.start) + BASE.slice(range.end)).toBe(cutBlock(BASE, target));
85
+ },
86
+ );
87
+ });
88
+
89
+ describe("cutTextRange", () => {
90
+ test("cuts only the selected characters inside a block", () => {
91
+ const paragraph = block(BASE, "spans two lines");
92
+ const start = paragraph.text.indexOf("paragraph");
93
+ const cut = cutTextRange(BASE, paragraph, start, paragraph, start + "paragraph".length);
94
+
95
+ expect(cut).toContain("A that\nspans two lines.");
96
+ expect(cut).toContain("## Context");
97
+ expect(cut).toContain("- first item");
98
+ });
99
+
100
+ test("preserves a list marker around a partial cut", () => {
101
+ const item = block(BASE, "first item");
102
+ const cut = cutTextRange(BASE, item, 0, item, "first ".length);
103
+
104
+ expect(cut).toContain("- item");
105
+ expect(cut).not.toContain("- first item");
106
+ });
107
+
108
+ test.each([
109
+ ["heading", "# remove keep", "# keep"],
110
+ ["ordered list", "12. remove keep", "12. keep"],
111
+ ["fenced code", "```ts\nremove keep\n```", "```ts\n keep\n```"],
112
+ ["frontmatter", "---\nname: remove keep\n---", "---\nname: keep\n---"],
113
+ ["table", "| remove | keep |\n| --- | --- |", "| | keep |\n| --- | --- |"],
114
+ ])("preserves %s source markers", (_label, source, expected) => {
115
+ const target = block(source, "remove");
116
+ const start = target.text.indexOf("remove");
117
+
118
+ expect(cutTextRange(source, target, start, target, start + "remove".length)).toBe(expected);
119
+ });
120
+
121
+ test("cuts an exact range across marked quote lines", () => {
122
+ const source = "> keep alpha\n> remove beta\n> keep gamma";
123
+ const quote = parseBlocks(source)[0]!;
124
+ const start = quote.text.indexOf("alpha");
125
+ const end = quote.text.indexOf("keep gamma");
126
+
127
+ const cut = cutTextRange(source, quote, start, quote, end);
128
+
129
+ expect(cut).toBe("> keep keep gamma");
130
+ });
131
+
132
+ test("cuts an exact range across separate blocks", () => {
133
+ const source = "Keep before remove this.\n\n- remove that keep after";
134
+ const blocks = parseBlocks(source);
135
+ const first = blocks[0]!;
136
+ const second = blocks[1]!;
137
+ const start = first.text.indexOf("remove");
138
+ const end = second.text.indexOf("keep after");
139
+
140
+ const cut = cutTextRange(source, first, start, second, end);
141
+
142
+ expect(cut).toBe("Keep before keep after");
143
+ });
144
+ });
145
+
146
+ describe("exact text Cuts", () => {
147
+ test("stores and applies the selected source characters exactly", () => {
148
+ const source = "It lands under unin Threads then in the sidebar.";
149
+ const start = source.indexOf("under");
150
+ const end = source.indexOf(" in the sidebar");
151
+ const cuts = mergeTextCut(source, [], start, end);
152
+
153
+ expect(cuts).toEqual([{ start, end, quote: "under unin Threads then" }]);
154
+ expect(applyTextCuts(source, cuts)).toBe("It lands in the sidebar.");
155
+ });
156
+
157
+ test("merges overlapping and adjacent ranges in one linear source interval", () => {
158
+ const source = "alpha beta gamma delta";
159
+ const first = mergeTextCut(source, [], 6, 10);
160
+ const merged = mergeTextCut(source, first, 10, 16);
161
+
162
+ expect(merged).toEqual([{ start: 6, end: 16, quote: "beta gamma" }]);
163
+ expect(applyTextCuts(source, merged)).toBe("alpha delta");
164
+ });
165
+
166
+ test("returns the original references for no-op ranges", () => {
167
+ const source = "unchanged";
168
+ const cuts = [{ start: 0, end: 2, quote: "un" }];
169
+
170
+ expect(mergeTextCut(source, cuts, 4, 4)).toBe(cuts);
171
+ expect(applyTextCuts(source, [])).toBe(source);
172
+ });
173
+
174
+ test("applies out-of-order Cuts and ignores stale or overlapping records", () => {
175
+ const source = "alpha beta gamma";
176
+
177
+ expect(
178
+ applyTextCuts(source, [
179
+ { start: 11, end: 16, quote: "gamma" },
180
+ { start: 0, end: 5, quote: "alpha" },
181
+ { start: 2, end: 8, quote: "stale" },
182
+ { start: 6, end: 10, quote: "beta" },
183
+ ]),
184
+ ).toBe(" ");
185
+ });
186
+
187
+ test("restores an exact subrange without disturbing the rest of its Cut", () => {
188
+ const source = "alpha beta gamma delta";
189
+ const cuts = mergeTextCut(source, [], 6, 16);
190
+ const restored = restoreTextCut(source, cuts, 11, 16);
191
+
192
+ expect(restored).toEqual([{ start: 6, end: 11, quote: "beta " }]);
193
+ expect(applyTextCuts(source, restored ?? [])).toBe("alpha gamma delta");
194
+ });
195
+
196
+ test("does not restore across separate Cuts", () => {
197
+ const source = "alpha beta gamma delta";
198
+ const cuts = [
199
+ { start: 6, end: 10, quote: "beta" },
200
+ { start: 11, end: 16, quote: "gamma" },
201
+ ];
202
+
203
+ expect(restoreTextCut(source, cuts, 6, 16)).toBeNull();
204
+ });
54
205
  });
55
206
 
56
207
  describe("restoreBlock", () => {
57
208
  test("restoring the only cut returns undefined - back to pristine", () => {
58
- const li = block(BASE, "second item");
59
- const cut = cutBlock(BASE, li);
209
+ // Arrange
210
+ const listItem = block(BASE, "second item");
211
+ const cut = cutBlock(BASE, listItem);
60
212
  // the cut block re-enters before the code fence, the next surviving block
61
- const next = parseBlocks(cut).find((b) => b.kind === "code");
62
- const restored = restoreBlock(BASE, cut, li, restoreLine(next, cut.split("\n").length));
213
+ const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
214
+
215
+ // Act
216
+ const restored = restoreBlock(BASE, cut, listItem, restoreLine(next, cut.split("\n").length));
217
+
218
+ // Assert
63
219
  expect(restored).toBeUndefined();
64
220
  });
65
221
 
66
222
  test("restore after another edit returns the merged working copy", () => {
67
- const li = block(BASE, "second item");
68
- const edited = cutBlock(BASE, li).replace("first item", "first item reworded");
69
- const next = parseBlocks(edited).find((b) => b.kind === "code");
70
- const restored = restoreBlock(BASE, edited, li, restoreLine(next, edited.split("\n").length));
223
+ // Arrange
224
+ const listItem = block(BASE, "second item");
225
+ const edited = cutBlock(BASE, listItem).replace("first item", "first item reworded");
226
+ const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
227
+
228
+ // Act
229
+ const restored = restoreBlock(
230
+ BASE,
231
+ edited,
232
+ listItem,
233
+ restoreLine(next, edited.split("\n").length),
234
+ );
235
+
236
+ // Assert
71
237
  expect(restored).toContain("- second item");
72
238
  expect(restored).toContain("first item reworded");
73
239
  });
74
240
 
75
241
  test("restore at the end of the document (no next block)", () => {
242
+ // Arrange
76
243
  const code = block(BASE, "const x");
77
244
  const cut = cutBlock(BASE, code);
245
+
246
+ // Act
78
247
  const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
248
+
249
+ // Assert
79
250
  expect(restored).toBeUndefined();
80
251
  });
81
252
 
82
253
  test("restore a multi-line block round-trips to pristine", () => {
83
- const p = block(BASE, "spans two lines");
84
- const cut = cutBlock(BASE, p);
85
- const next = parseBlocks(cut).find((b) => b.text === "first item");
86
- const restored = restoreBlock(BASE, cut, p, restoreLine(next, cut.split("\n").length));
254
+ // Arrange
255
+ const paragraph = block(BASE, "spans two lines");
256
+ const cut = cutBlock(BASE, paragraph);
257
+ const next = parseBlocks(cut).find((block) => block.text === "first item");
258
+
259
+ // Act
260
+ const restored = restoreBlock(BASE, cut, paragraph, restoreLine(next, cut.split("\n").length));
261
+
262
+ // Assert
87
263
  expect(restored).toBeUndefined();
88
264
  });
89
265
 
90
266
  test("signature rule ignores blank-line layout but not text changes", () => {
91
- const li = block(BASE, "second item");
92
- const cut = cutBlock(BASE, li);
93
- const next = parseBlocks(cut).find((b) => b.kind === "code");
267
+ // Arrange
268
+ const listItem = block(BASE, "second item");
269
+ const cut = cutBlock(BASE, listItem);
270
+ const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
94
271
  const line = restoreLine(next, cut.split("\n").length);
272
+
273
+ // Assert
95
274
  // extra blank lines elsewhere do not block pristine detection
96
- expect(restoreBlock(BASE, cut.replace("## Context", "## Context\n"), li, line)).toBeUndefined();
275
+ expect(
276
+ restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line),
277
+ ).toBeUndefined();
97
278
  // a real text change keeps the working copy alive
98
- const changed = restoreBlock(BASE, cut.replace("first", "1st"), li, line);
279
+ const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
280
+
99
281
  expect(changed).toContain("1st item");
100
282
  });
101
283
  });
102
284
 
103
285
  describe("restoreLine", () => {
104
286
  test("next surviving block's start, or the line count at the end", () => {
105
- const li = block(BASE, "first item");
106
- expect(restoreLine(li, 99)).toBe(li.lineStart);
287
+ // Arrange
288
+ const listItem = block(BASE, "first item");
289
+
290
+ // Assert
291
+ expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
107
292
  expect(restoreLine(undefined, 99)).toBe(99);
108
293
  });
109
294
  });
@@ -1,15 +1,95 @@
1
- /**
2
- * Working-copy block surgery: the only module that slices raw source text by
3
- * Block line ranges. Cut removes a block's lines; restore re-inserts a base
4
- * block's chunk and reports when the working copy round-trips back to the
5
- * submitted revision (pristine). Callers never touch lineStart/lineEnd.
6
- */
1
+ /** Working-copy source surgery for block and character cuts plus block restoration. */
2
+
3
+ import { parseBlocks, sourceOffsetAt, type Block } from "./markdown";
4
+ import type { TextCut } from "./types";
5
+
6
+ /** Add one source range and normalize overlaps without copying on a no-op. */
7
+ export function mergeTextCut(
8
+ source: string,
9
+ cuts: readonly TextCut[],
10
+ start: number,
11
+ end: number,
12
+ ): TextCut[] {
13
+ const boundedStart = Math.max(0, Math.min(start, source.length));
14
+ const boundedEnd = Math.max(boundedStart, Math.min(end, source.length));
15
+
16
+ if (boundedEnd === boundedStart) {
17
+ // SAFETY: a no-op returns the caller's array unchanged; callers never mutate the result.
18
+ return cuts as TextCut[];
19
+ }
20
+ const ranges = [
21
+ ...cuts.map((cut) => ({ start: cut.start, end: cut.end })),
22
+ { start: boundedStart, end: boundedEnd },
23
+ ]
24
+ .map((range) => ({
25
+ start: Math.max(0, Math.min(range.start, source.length)),
26
+ end: Math.max(0, Math.min(range.end, source.length)),
27
+ }))
28
+ .filter((range) => range.end > range.start)
29
+ .toSorted((left, right) => left.start - right.start || left.end - right.end);
30
+ const merged: Array<{ start: number; end: number }> = [];
31
+
32
+ for (const range of ranges) {
33
+ const previous = merged.at(-1);
34
+
35
+ if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end);
36
+ else merged.push(range);
37
+ }
38
+
39
+ return merged.map((range) => ({
40
+ ...range,
41
+ quote: source.slice(range.start, range.end),
42
+ }));
43
+ }
44
+
45
+ /** Restore a selected part of one contiguous character Cut. */
46
+ export function restoreTextCut(
47
+ source: string,
48
+ cuts: readonly TextCut[],
49
+ start: number,
50
+ end: number,
51
+ ): TextCut[] | null {
52
+ const cutIndex = cuts.findIndex((cut) => cut.start <= start && end <= cut.end);
7
53
 
8
- import { parseBlocks, type Block } from "./markdown";
54
+ if (cutIndex === -1 || end <= start) return null;
55
+ const cut = cuts[cutIndex]!;
56
+ const replacements: TextCut[] = [];
57
+
58
+ if (cut.start < start) {
59
+ replacements.push({ start: cut.start, end: start, quote: source.slice(cut.start, start) });
60
+ }
61
+ if (end < cut.end) {
62
+ replacements.push({ start: end, end: cut.end, quote: source.slice(end, cut.end) });
63
+ }
64
+
65
+ return [...cuts.slice(0, cutIndex), ...replacements, ...cuts.slice(cutIndex + 1)];
66
+ }
67
+
68
+ /** Apply valid source ranges in source order, ignoring stale or overlapping records. */
69
+ export function applyTextCuts(source: string, cuts: readonly TextCut[]): string {
70
+ if (cuts.length === 0) return source;
71
+ let cursor = 0;
72
+ const pieces: string[] = [];
73
+
74
+ for (const cut of cuts.toSorted(
75
+ (left, right) => left.start - right.start || left.end - right.end,
76
+ )) {
77
+ if (cut.start < cursor || cut.start < 0 || cut.end > source.length || cut.end <= cut.start)
78
+ continue;
79
+ if (source.slice(cut.start, cut.end) !== cut.quote) continue;
80
+
81
+ pieces.push(source.slice(cursor, cut.start));
82
+ cursor = cut.end;
83
+ }
84
+ pieces.push(source.slice(cursor));
85
+
86
+ return pieces.join("");
87
+ }
9
88
 
10
89
  /** Chunk of the base source a block occupies (for restore and display). */
11
90
  export function sourceChunk(base: string, block: Block): string {
12
91
  const lines = base.split("\n");
92
+
13
93
  return lines.slice(block.lineStart, block.lineEnd + 1).join("\n");
14
94
  }
15
95
 
@@ -18,10 +98,50 @@ export function cutBlock(working: string, block: Block): string {
18
98
  const lines = working.split("\n");
19
99
  const before = lines.slice(0, block.lineStart);
20
100
  const after = lines.slice(block.lineEnd + 1);
101
+
21
102
  while (before.length && before[before.length - 1]!.trim() === "") before.pop();
103
+
22
104
  return [...before, ...after].join("\n");
23
105
  }
24
106
 
107
+ /** Source interval removed by a whole-block Cut, including its leading blank-line padding. */
108
+ export function blockCutSourceRange(source: string, block: Block) {
109
+ const lines = source.split("\n");
110
+ let startLine = block.lineStart;
111
+
112
+ while (startLine > 0 && lines[startLine - 1]!.trim() === "") startLine--;
113
+ const lineStarts = [0];
114
+
115
+ for (let index = 0; index < source.length; index++) {
116
+ if (source.charCodeAt(index) === 10) lineStarts.push(index + 1);
117
+ }
118
+
119
+ return {
120
+ start: lineStarts[startLine] ?? source.length,
121
+ end: lineStarts[block.lineEnd + 1] ?? source.length,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Remove exactly the selected block-text range while retaining surrounding
127
+ * Markdown markers. A cross-block range also removes the source between its
128
+ * endpoint characters.
129
+ */
130
+ export function cutTextRange(
131
+ working: string,
132
+ startBlock: Block,
133
+ start: number,
134
+ endBlock: Block,
135
+ end: number,
136
+ ): string {
137
+ const sourceStart = sourceOffsetAt(working, startBlock, start) ?? -1;
138
+ const sourceEnd = sourceOffsetAt(working, endBlock, end) ?? -1;
139
+
140
+ if (sourceStart < 0 || sourceEnd <= sourceStart) return working;
141
+
142
+ return working.slice(0, sourceStart) + working.slice(sourceEnd);
143
+ }
144
+
25
145
  /** The line a restored block re-enters at: the next surviving block's start. */
26
146
  export function restoreLine(next: Block | undefined, workingLineCount: number): number {
27
147
  return next ? next.lineStart : workingLineCount;
@@ -34,10 +154,24 @@ export function restoreLine(next: Block | undefined, workingLineCount: number):
34
154
  */
35
155
  function signature(text: string): string {
36
156
  return parseBlocks(text)
37
- .map((b) => b.kind + " " + b.text)
157
+ .map((block) => block.kind + " " + block.text)
38
158
  .join("\0");
39
159
  }
40
160
 
161
+ /**
162
+ * Whether a base block is missing from the working source: the working copy
163
+ * holds fewer blocks with its kind and text than the base does. Only such a
164
+ * block can be restored without duplicating content.
165
+ */
166
+ export function isBlockCut(base: string, working: string, block: Block): boolean {
167
+ const matches = (text: string) =>
168
+ parseBlocks(text).filter(
169
+ (candidate) => candidate.kind === block.kind && candidate.text === block.text,
170
+ ).length;
171
+
172
+ return matches(working) < matches(base);
173
+ }
174
+
41
175
  /**
42
176
  * Re-insert a cut base block into the working source before `beforeLine`.
43
177
  * Returns undefined when the block structure round-trips to the base content
@@ -53,9 +187,11 @@ export function restoreBlock(
53
187
  const lines = working.split("\n");
54
188
  const before = lines.slice(0, beforeLine);
55
189
  const after = lines.slice(beforeLine);
56
- const ins = sourceChunk(base, block).split("\n");
57
- if (before.length && before[before.length - 1]!.trim() !== "") ins.unshift("");
58
- if (after.length && after[0]!.trim() !== "") ins.push("");
59
- const restored = [...before, ...ins, ...after].join("\n");
190
+ const insertedLines = sourceChunk(base, block).split("\n");
191
+
192
+ if (before.length && before[before.length - 1]!.trim() !== "") insertedLines.unshift("");
193
+ if (after.length && after[0]!.trim() !== "") insertedLines.push("");
194
+ const restored = [...before, ...insertedLines, ...after].join("\n");
195
+
60
196
  return signature(restored) === signature(base) ? undefined : restored;
61
197
  }