@cueloop/schema 0.1.0-alpha.0 → 0.1.0-alpha.100

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.
@@ -0,0 +1,197 @@
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);
53
+
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
+ }
88
+
89
+ /** Chunk of the base source a block occupies (for restore and display). */
90
+ export function sourceChunk(base: string, block: Block): string {
91
+ const lines = base.split("\n");
92
+
93
+ return lines.slice(block.lineStart, block.lineEnd + 1).join("\n");
94
+ }
95
+
96
+ /** Remove a work block's lines from the working source (Cut). */
97
+ export function cutBlock(working: string, block: Block): string {
98
+ const lines = working.split("\n");
99
+ const before = lines.slice(0, block.lineStart);
100
+ const after = lines.slice(block.lineEnd + 1);
101
+
102
+ while (before.length && before[before.length - 1]!.trim() === "") before.pop();
103
+
104
+ return [...before, ...after].join("\n");
105
+ }
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
+
145
+ /** The line a restored block re-enters at: the next surviving block's start. */
146
+ export function restoreLine(next: Block | undefined, workingLineCount: number): number {
147
+ return next ? next.lineStart : workingLineCount;
148
+ }
149
+
150
+ /**
151
+ * Block-structure signature: kind + text of every block, insensitive to
152
+ * blank-line layout between blocks. NUL separates blocks so no block text
153
+ * can fake a boundary.
154
+ */
155
+ function signature(text: string): string {
156
+ return parseBlocks(text)
157
+ .map((block) => block.kind + " " + block.text)
158
+ .join("\0");
159
+ }
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
+
175
+ /**
176
+ * Re-insert a cut base block into the working source before `beforeLine`.
177
+ * Returns undefined when the block structure round-trips to the base content
178
+ * (restore may differ in blank-line placement only) - the working copy is
179
+ * back to pristine and should be dropped.
180
+ */
181
+ export function restoreBlock(
182
+ base: string,
183
+ working: string,
184
+ block: Block,
185
+ beforeLine: number,
186
+ ): string | undefined {
187
+ const lines = working.split("\n");
188
+ const before = lines.slice(0, beforeLine);
189
+ const after = lines.slice(beforeLine);
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
+
196
+ return signature(restored) === signature(base) ? undefined : restored;
197
+ }