@abelfubu/dv 0.1.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/dist/ansi-html.d.ts +42 -0
- package/dist/ansi-html.d.ts.map +1 -0
- package/dist/ansi-html.js +327 -0
- package/dist/ansi-output.d.ts +22 -0
- package/dist/ansi-output.d.ts.map +1 -0
- package/dist/ansi-output.js +154 -0
- package/dist/balance-delimiters.d.ts +25 -0
- package/dist/balance-delimiters.d.ts.map +1 -0
- package/dist/balance-delimiters.js +539 -0
- package/dist/balance-delimiters.test.d.ts +2 -0
- package/dist/balance-delimiters.test.d.ts.map +1 -0
- package/dist/balance-delimiters.test.js +1029 -0
- package/dist/cli-copy-notification.test.d.ts +2 -0
- package/dist/cli-copy-notification.test.d.ts.map +1 -0
- package/dist/cli-copy-notification.test.js +80 -0
- package/dist/cli-scroll.test.d.ts +2 -0
- package/dist/cli-scroll.test.d.ts.map +1 -0
- package/dist/cli-scroll.test.js +283 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +976 -0
- package/dist/clipboard.d.ts +16 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +128 -0
- package/dist/components/diff-view.d.ts +32 -0
- package/dist/components/diff-view.d.ts.map +1 -0
- package/dist/components/diff-view.js +123 -0
- package/dist/components/diff-view.test.d.ts +5 -0
- package/dist/components/diff-view.test.d.ts.map +1 -0
- package/dist/components/diff-view.test.js +312 -0
- package/dist/components/directory-tree-view.d.ts +33 -0
- package/dist/components/directory-tree-view.d.ts.map +1 -0
- package/dist/components/directory-tree-view.js +262 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +5 -0
- package/dist/components/toast.d.ts +21 -0
- package/dist/components/toast.d.ts.map +1 -0
- package/dist/components/toast.js +47 -0
- package/dist/diff-cursor-utils.d.ts +20 -0
- package/dist/diff-cursor-utils.d.ts.map +1 -0
- package/dist/diff-cursor-utils.js +105 -0
- package/dist/diff-cursor-utils.test.d.ts +2 -0
- package/dist/diff-cursor-utils.test.d.ts.map +1 -0
- package/dist/diff-cursor-utils.test.js +40 -0
- package/dist/diff-surface-copy.d.ts +23 -0
- package/dist/diff-surface-copy.d.ts.map +1 -0
- package/dist/diff-surface-copy.js +64 -0
- package/dist/diff-surface-copy.test.d.ts +5 -0
- package/dist/diff-surface-copy.test.d.ts.map +1 -0
- package/dist/diff-surface-copy.test.js +142 -0
- package/dist/diff-utils.d.ts +196 -0
- package/dist/diff-utils.d.ts.map +1 -0
- package/dist/diff-utils.js +682 -0
- package/dist/diff-utils.test.d.ts +2 -0
- package/dist/diff-utils.test.d.ts.map +1 -0
- package/dist/diff-utils.test.js +727 -0
- package/dist/directory-tree.d.ts +72 -0
- package/dist/directory-tree.d.ts.map +1 -0
- package/dist/directory-tree.js +161 -0
- package/dist/directory-tree.test.d.ts +2 -0
- package/dist/directory-tree.test.d.ts.map +1 -0
- package/dist/directory-tree.test.js +383 -0
- package/dist/dropdown.d.ts +26 -0
- package/dist/dropdown.d.ts.map +1 -0
- package/dist/dropdown.js +172 -0
- package/dist/dropdown.test.d.ts +2 -0
- package/dist/dropdown.test.d.ts.map +1 -0
- package/dist/dropdown.test.js +106 -0
- package/dist/filter-submodule.e2e.test.d.ts +2 -0
- package/dist/filter-submodule.e2e.test.d.ts.map +1 -0
- package/dist/filter-submodule.e2e.test.js +109 -0
- package/dist/hooks/use-copy-selection.d.ts +29 -0
- package/dist/hooks/use-copy-selection.d.ts.map +1 -0
- package/dist/hooks/use-copy-selection.js +46 -0
- package/dist/kv-codec.d.ts +16 -0
- package/dist/kv-codec.d.ts.map +1 -0
- package/dist/kv-codec.js +36 -0
- package/dist/license.d.ts +14 -0
- package/dist/license.d.ts.map +1 -0
- package/dist/license.js +63 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +78 -0
- package/dist/monochrome.d.ts +34 -0
- package/dist/monochrome.d.ts.map +1 -0
- package/dist/monochrome.js +613 -0
- package/dist/monotone.d.ts +22 -0
- package/dist/monotone.d.ts.map +1 -0
- package/dist/monotone.js +185 -0
- package/dist/parsers-config.d.ts +19 -0
- package/dist/parsers-config.d.ts.map +1 -0
- package/dist/parsers-config.js +271 -0
- package/dist/patch-terminal-dimensions.d.ts +2 -0
- package/dist/patch-terminal-dimensions.d.ts.map +1 -0
- package/dist/patch-terminal-dimensions.js +45 -0
- package/dist/stdin-pager.test.d.ts +2 -0
- package/dist/stdin-pager.test.d.ts.map +1 -0
- package/dist/stdin-pager.test.js +497 -0
- package/dist/store.d.ts +16 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +48 -0
- package/dist/themes/github.json +247 -0
- package/dist/themes.d.ts +59 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +248 -0
- package/dist/tree-icons.d.ts +4 -0
- package/dist/tree-icons.d.ts.map +1 -0
- package/dist/tree-icons.js +18 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +13 -0
- package/dist/web-utils.d.ts +56 -0
- package/dist/web-utils.d.ts.map +1 -0
- package/dist/web-utils.js +363 -0
- package/package.json +37 -0
- package/public/jetbrains-mono-nerd.ttf +0 -0
- package/public/jetbrains-mono-nerd.woff2 +0 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// Delimiter balancing for syntax highlighting in diff hunks.
|
|
2
|
+
//
|
|
3
|
+
// When a diff hunk starts or ends inside a paired delimiter (template
|
|
4
|
+
// literal, triple-quoted string, fenced code block, etc.), tree-sitter can
|
|
5
|
+
// misparse everything after the unmatched token.
|
|
6
|
+
//
|
|
7
|
+
// Boundary repair strategy:
|
|
8
|
+
// 1. Tokenizer: count delimiter occurrences in each hunk's content,
|
|
9
|
+
// skipping escaped characters.
|
|
10
|
+
// 2. Repair symmetric delimiters by inserting a synthetic opening/closing
|
|
11
|
+
// token on an existing content line so the real boundary token can still
|
|
12
|
+
// pair and reset parser state.
|
|
13
|
+
// 3. Repair asymmetric delimiters by appending the closing token to the
|
|
14
|
+
// last content line in the hunk so later hunks do not inherit state.
|
|
15
|
+
//
|
|
16
|
+
// For strings/docstrings, the real boundary token still matters. Escaping a
|
|
17
|
+
// closing token that actually ends an outer template/docstring leaves the
|
|
18
|
+
// parser stuck inside that string and kills highlighting for the rest of the
|
|
19
|
+
// hunk. Instead, synthesize the missing pair on an existing line and keep the
|
|
20
|
+
// original closer/opener intact. Markdown fences are the exception: their
|
|
21
|
+
// contextual open/close classification lets us safely add inline fence context
|
|
22
|
+
// without rewriting hunk headers.
|
|
23
|
+
const cStyleBlockCommentRule = {
|
|
24
|
+
token: "/*",
|
|
25
|
+
closeToken: "*/",
|
|
26
|
+
};
|
|
27
|
+
const htmlCommentRule = {
|
|
28
|
+
token: "<!--",
|
|
29
|
+
closeToken: "-->",
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Delimiters to balance per language filetype.
|
|
33
|
+
*
|
|
34
|
+
* Each entry maps a filetype (from detectFiletype) to the list of delimiters
|
|
35
|
+
* that come in open/close pairs and can span lines.
|
|
36
|
+
*/
|
|
37
|
+
const LANGUAGE_DELIMITERS = {
|
|
38
|
+
typescript: [{ token: "`" }, cStyleBlockCommentRule],
|
|
39
|
+
python: [{ token: '"""' }, { token: "'''" }],
|
|
40
|
+
markdown: [{ token: "```", classifyFence: classifyMarkdownFence }],
|
|
41
|
+
go: [{ token: "`" }, cStyleBlockCommentRule],
|
|
42
|
+
rust: [cStyleBlockCommentRule],
|
|
43
|
+
cpp: [cStyleBlockCommentRule],
|
|
44
|
+
csharp: [cStyleBlockCommentRule],
|
|
45
|
+
c: [cStyleBlockCommentRule],
|
|
46
|
+
java: [cStyleBlockCommentRule],
|
|
47
|
+
php: [cStyleBlockCommentRule],
|
|
48
|
+
scala: [{ token: '"""' }, cStyleBlockCommentRule],
|
|
49
|
+
html: [htmlCommentRule],
|
|
50
|
+
css: [cStyleBlockCommentRule],
|
|
51
|
+
swift: [{ token: '"""' }, cStyleBlockCommentRule],
|
|
52
|
+
julia: [{ token: '"""' }],
|
|
53
|
+
};
|
|
54
|
+
function isQuotedBacktickLiteral(code, column) {
|
|
55
|
+
const quote = code[column - 1];
|
|
56
|
+
return (quote === "'" || quote === '"') && code[column + 1] === quote;
|
|
57
|
+
}
|
|
58
|
+
function findDelimiterColumns(code, delimiter) {
|
|
59
|
+
const columns = [];
|
|
60
|
+
const len = delimiter.length;
|
|
61
|
+
for (let i = 0; i < code.length; i++) {
|
|
62
|
+
if (code[i] === "\\") {
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
else if (code.startsWith(delimiter, i)) {
|
|
66
|
+
if (!(delimiter === "`" && isQuotedBacktickLiteral(code, i))) {
|
|
67
|
+
columns.push(i);
|
|
68
|
+
}
|
|
69
|
+
i += len - 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return columns;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Count unescaped occurrences of a delimiter in a code string.
|
|
76
|
+
*
|
|
77
|
+
* Walks character by character. Backslash skips the next character,
|
|
78
|
+
* otherwise checks for the delimiter at the current position.
|
|
79
|
+
* Handles both single-char (`) and multi-char (""") delimiters.
|
|
80
|
+
*/
|
|
81
|
+
export function countDelimiter(code, delimiter) {
|
|
82
|
+
return findDelimiterColumns(code, delimiter).length;
|
|
83
|
+
}
|
|
84
|
+
function isDiffContentLine(line) {
|
|
85
|
+
return line[0] === " " || line[0] === "+" || line[0] === "-";
|
|
86
|
+
}
|
|
87
|
+
function getContentLines(lines) {
|
|
88
|
+
return lines.flatMap((line, hunkLineIndex) => isDiffContentLine(line)
|
|
89
|
+
? [{ hunkLineIndex, content: line.slice(1) }]
|
|
90
|
+
: []);
|
|
91
|
+
}
|
|
92
|
+
function findDelimiterOccurrences(contentLines, delimiter) {
|
|
93
|
+
const occurrences = [];
|
|
94
|
+
for (const [contentLineIndex, line] of contentLines.entries()) {
|
|
95
|
+
const content = line.content;
|
|
96
|
+
for (const column of findDelimiterColumns(content, delimiter)) {
|
|
97
|
+
occurrences.push({
|
|
98
|
+
contentLineIndex,
|
|
99
|
+
hunkLineIndex: line.hunkLineIndex,
|
|
100
|
+
column,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return occurrences;
|
|
105
|
+
}
|
|
106
|
+
function findAnyDelimiterOccurrences(contentLines, delimiters) {
|
|
107
|
+
const ordered = [...delimiters].sort((a, b) => b.length - a.length);
|
|
108
|
+
const occurrences = [];
|
|
109
|
+
for (const [contentLineIndex, line] of contentLines.entries()) {
|
|
110
|
+
const content = line.content;
|
|
111
|
+
for (let column = 0; column < content.length; column++) {
|
|
112
|
+
if (content[column] === "\\") {
|
|
113
|
+
column++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const matched = ordered.find((delimiter) => content.startsWith(delimiter, column));
|
|
117
|
+
if (!matched) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
occurrences.push({
|
|
121
|
+
contentLineIndex,
|
|
122
|
+
hunkLineIndex: line.hunkLineIndex,
|
|
123
|
+
column,
|
|
124
|
+
});
|
|
125
|
+
column += matched.length - 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return occurrences;
|
|
129
|
+
}
|
|
130
|
+
function getPreviousNonWhitespaceChar(content, column) {
|
|
131
|
+
for (let i = column - 1; i >= 0; i--) {
|
|
132
|
+
const char = content[i];
|
|
133
|
+
if (char && !/\s/.test(char)) {
|
|
134
|
+
return char;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
function getNextNonWhitespaceChar(content, column) {
|
|
140
|
+
for (let i = column; i < content.length; i++) {
|
|
141
|
+
const char = content[i];
|
|
142
|
+
if (char && !/\s/.test(char)) {
|
|
143
|
+
return char;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
function hasNonEmptyContentBefore(contentLines, contentLineIndex) {
|
|
149
|
+
return contentLines.slice(0, contentLineIndex).some((line) => line.content.trim() !== "");
|
|
150
|
+
}
|
|
151
|
+
function hasNonEmptyContentAfter(contentLines, contentLineIndex) {
|
|
152
|
+
return contentLines.slice(contentLineIndex + 1).some((line) => line.content.trim() !== "");
|
|
153
|
+
}
|
|
154
|
+
function classifyOccurrence(contentLines, occurrence, token) {
|
|
155
|
+
const content = contentLines[occurrence.contentLineIndex]?.content;
|
|
156
|
+
if (content === undefined) {
|
|
157
|
+
return "unknown";
|
|
158
|
+
}
|
|
159
|
+
const before = getPreviousNonWhitespaceChar(content, occurrence.column);
|
|
160
|
+
const after = getNextNonWhitespaceChar(content, occurrence.column + token.length);
|
|
161
|
+
const trimmed = content.trim();
|
|
162
|
+
const hasBeforeLines = hasNonEmptyContentBefore(contentLines, occurrence.contentLineIndex);
|
|
163
|
+
const hasAfterLines = hasNonEmptyContentAfter(contentLines, occurrence.contentLineIndex);
|
|
164
|
+
if (token.length > 1) {
|
|
165
|
+
if (trimmed === token) {
|
|
166
|
+
if (hasBeforeLines)
|
|
167
|
+
return "close";
|
|
168
|
+
if (hasAfterLines)
|
|
169
|
+
return "open";
|
|
170
|
+
return "unknown";
|
|
171
|
+
}
|
|
172
|
+
if (trimmed.startsWith(token)) {
|
|
173
|
+
if (hasBeforeLines && (!after || /[.\])};:,]/.test(after))) {
|
|
174
|
+
return "close";
|
|
175
|
+
}
|
|
176
|
+
if (after) {
|
|
177
|
+
return "open";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (trimmed.endsWith(token)) {
|
|
181
|
+
return "close";
|
|
182
|
+
}
|
|
183
|
+
return "unknown";
|
|
184
|
+
}
|
|
185
|
+
if (trimmed === token) {
|
|
186
|
+
if (hasBeforeLines || hasAfterLines)
|
|
187
|
+
return "close";
|
|
188
|
+
return "unknown";
|
|
189
|
+
}
|
|
190
|
+
if (!before && after) {
|
|
191
|
+
return "open";
|
|
192
|
+
}
|
|
193
|
+
if (before && !after) {
|
|
194
|
+
return "close";
|
|
195
|
+
}
|
|
196
|
+
if (after && /[$A-Za-z0-9_{[(]/.test(after)) {
|
|
197
|
+
return "open";
|
|
198
|
+
}
|
|
199
|
+
if (before && after && /[)\]};:.,]/.test(after)) {
|
|
200
|
+
return "close";
|
|
201
|
+
}
|
|
202
|
+
return "unknown";
|
|
203
|
+
}
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Contextual fence classification and repair (markdown code fences)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
/**
|
|
208
|
+
* Classify a ``` occurrence as a markdown fence opener, closer, or not a fence.
|
|
209
|
+
*
|
|
210
|
+
* Returns null if the occurrence is not a valid block-level fence (e.g. inline
|
|
211
|
+
* triple-backtick in prose, or indented more than 3 spaces).
|
|
212
|
+
* Returns "open" if followed by an info string (language tag).
|
|
213
|
+
* Returns "close" if nothing follows the backtick run (only whitespace).
|
|
214
|
+
*/
|
|
215
|
+
function classifyMarkdownFence(content, column) {
|
|
216
|
+
// Must be at start of line with at most 3 spaces of indentation
|
|
217
|
+
const beforeFence = content.slice(0, column);
|
|
218
|
+
if (beforeFence.length > 3 || /\S/.test(beforeFence))
|
|
219
|
+
return null;
|
|
220
|
+
// Count consecutive backticks (support 4+ backtick fences per CommonMark)
|
|
221
|
+
let fenceLen = 0;
|
|
222
|
+
for (let i = column; i < content.length && content[i] === "`"; i++)
|
|
223
|
+
fenceLen++;
|
|
224
|
+
if (fenceLen < 3)
|
|
225
|
+
return null;
|
|
226
|
+
// What comes after the backtick run?
|
|
227
|
+
const afterFence = content.slice(column + fenceLen).trim();
|
|
228
|
+
// Closing fence: nothing after backticks (only whitespace)
|
|
229
|
+
if (!afterFence)
|
|
230
|
+
return "close";
|
|
231
|
+
// Opening fence: has info string (language tag)
|
|
232
|
+
// Info string must not contain backticks (CommonMark spec)
|
|
233
|
+
if (!afterFence.includes("`"))
|
|
234
|
+
return "open";
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Simulate a sequential walk through classified fences to detect boundary
|
|
239
|
+
* artifacts. Markdown code fences don't nest, so depth toggles between 0
|
|
240
|
+
* (outside) and 1 (inside).
|
|
241
|
+
*
|
|
242
|
+
* - "open" (has info string) always pushes depth to 1.
|
|
243
|
+
* - "close" (bare fence) decrements if inside, or acts as a bare opener
|
|
244
|
+
* if already outside (a code block without a language tag).
|
|
245
|
+
*
|
|
246
|
+
* Conflicts are counted when a must-open fires while already inside (depth
|
|
247
|
+
* was already 1) or when other impossible transitions occur.
|
|
248
|
+
*/
|
|
249
|
+
function walkFences(fences, startDepth) {
|
|
250
|
+
let depth = startDepth;
|
|
251
|
+
let conflicts = 0;
|
|
252
|
+
for (const fence of fences) {
|
|
253
|
+
if (fence.kind === "open") {
|
|
254
|
+
if (depth > 0)
|
|
255
|
+
conflicts++;
|
|
256
|
+
depth = 1;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
// "close" (bare fence): close if inside, otherwise bare opener
|
|
260
|
+
if (depth > 0) {
|
|
261
|
+
depth = 0;
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
depth = 1;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { startDepth, endDepth: depth, conflicts };
|
|
269
|
+
}
|
|
270
|
+
function walkAsymmetricOccurrences(occurrences, startDepth) {
|
|
271
|
+
let depth = startDepth;
|
|
272
|
+
let conflicts = 0;
|
|
273
|
+
for (const occurrence of occurrences) {
|
|
274
|
+
if (occurrence.kind === "open") {
|
|
275
|
+
depth++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (depth > 0) {
|
|
279
|
+
depth--;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
conflicts++;
|
|
283
|
+
}
|
|
284
|
+
return { startDepth, endDepth: depth, conflicts };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Repair context-dependent fences (like markdown ```) using sequential
|
|
288
|
+
* open/close pairing instead of simple odd/even counting.
|
|
289
|
+
*
|
|
290
|
+
* Tries two starting states (outside vs inside a code block), picks the
|
|
291
|
+
* walk with fewer conflicts, and adds synthetic fence tokens inline:
|
|
292
|
+
* - If the hunk starts inside a block: prepend ``` to the first content
|
|
293
|
+
* line so the boundary closer has something to close.
|
|
294
|
+
* - If the hunk ends inside a block: append ``` to the last content
|
|
295
|
+
* line so the boundary opener is properly closed.
|
|
296
|
+
* Tokens are added inline (no new lines) to preserve patch header counts.
|
|
297
|
+
*/
|
|
298
|
+
function repairContextualFences(contentLines, lines, rule) {
|
|
299
|
+
if (!rule.classifyFence)
|
|
300
|
+
return [...lines];
|
|
301
|
+
const occurrences = findDelimiterOccurrences(contentLines, rule.token);
|
|
302
|
+
if (occurrences.length === 0)
|
|
303
|
+
return [...lines];
|
|
304
|
+
// Classify each occurrence as fence open, close, or not-a-fence
|
|
305
|
+
const fences = [];
|
|
306
|
+
for (const occ of occurrences) {
|
|
307
|
+
const content = contentLines[occ.contentLineIndex]?.content;
|
|
308
|
+
if (!content)
|
|
309
|
+
continue;
|
|
310
|
+
const kind = rule.classifyFence(content, occ.column);
|
|
311
|
+
if (kind) {
|
|
312
|
+
fences.push({ occurrence: occ, kind });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (fences.length === 0)
|
|
316
|
+
return [...lines];
|
|
317
|
+
// Try both starting states
|
|
318
|
+
const walk0 = walkFences(fences, 0);
|
|
319
|
+
const walk1 = walkFences(fences, 1);
|
|
320
|
+
// Pick better walk: fewer conflicts → fewer repairs → content heuristic
|
|
321
|
+
let chosen;
|
|
322
|
+
if (walk0.conflicts < walk1.conflicts) {
|
|
323
|
+
chosen = walk0;
|
|
324
|
+
}
|
|
325
|
+
else if (walk1.conflicts < walk0.conflicts) {
|
|
326
|
+
chosen = walk1;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
const repairs0 = (walk0.startDepth > 0 ? 1 : 0) + (walk0.endDepth > 0 ? 1 : 0);
|
|
330
|
+
const repairs1 = (walk1.startDepth > 0 ? 1 : 0) + (walk1.endDepth > 0 ? 1 : 0);
|
|
331
|
+
if (repairs0 < repairs1) {
|
|
332
|
+
chosen = walk0;
|
|
333
|
+
}
|
|
334
|
+
else if (repairs1 < repairs0) {
|
|
335
|
+
chosen = walk1;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
// Still tied: disambiguate by content position relative to the first fence.
|
|
339
|
+
// If there's non-empty content before the first fence in the hunk, the fence
|
|
340
|
+
// is likely closing a block from before the hunk → prefer depth=1 (starts inside).
|
|
341
|
+
// This produces better tree-sitter pairing: the prepended ``` + original ```
|
|
342
|
+
// form a matched pair, while appended inline ``` doesn't close a fence.
|
|
343
|
+
const firstIdx = fences[0]?.occurrence.contentLineIndex ?? 0;
|
|
344
|
+
const hasContentBefore = contentLines.slice(0, firstIdx).some((line) => line.content.trim() !== "");
|
|
345
|
+
chosen = hasContentBefore ? walk1 : walk0;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
let result = [...lines];
|
|
349
|
+
// Append synthetic closer to end of last content line (inline, no new lines)
|
|
350
|
+
if (chosen.endDepth > 0) {
|
|
351
|
+
result = appendClosingTokensToLastContentLine(result, rule.token, 1);
|
|
352
|
+
}
|
|
353
|
+
// Prepend synthetic opener to start of first content line (inline, no new lines).
|
|
354
|
+
// Pass the first fence's hunk line index so the search only looks at lines
|
|
355
|
+
// before the boundary closer — the opener must precede it to form a pair.
|
|
356
|
+
if (chosen.startDepth > 0) {
|
|
357
|
+
const firstFenceHunkLine = fences[0]?.occurrence.hunkLineIndex;
|
|
358
|
+
result = prependOpeningTokenToFirstContentLine(result, rule.token, firstFenceHunkLine);
|
|
359
|
+
}
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
function getRuleOpenTokens(rule) {
|
|
363
|
+
return rule.openTokens ?? [rule.token];
|
|
364
|
+
}
|
|
365
|
+
function getRuleCloseToken(rule) {
|
|
366
|
+
return rule.closeToken ?? rule.token;
|
|
367
|
+
}
|
|
368
|
+
function isSymmetricRule(rule) {
|
|
369
|
+
const openTokens = getRuleOpenTokens(rule);
|
|
370
|
+
const closeToken = getRuleCloseToken(rule);
|
|
371
|
+
return openTokens.length === 1 && openTokens[0] === closeToken;
|
|
372
|
+
}
|
|
373
|
+
function getUnclosedTokenCount(lines, rule) {
|
|
374
|
+
const contentLines = getContentLines(lines);
|
|
375
|
+
const openTokens = getRuleOpenTokens(rule);
|
|
376
|
+
const closeToken = getRuleCloseToken(rule);
|
|
377
|
+
if (isSymmetricRule(rule)) {
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
const orderedTokens = [...openTokens, closeToken];
|
|
381
|
+
const occurrences = findAnyDelimiterOccurrences(contentLines, orderedTokens);
|
|
382
|
+
if (occurrences.length === 0) {
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
const classified = [];
|
|
386
|
+
for (const occurrence of occurrences) {
|
|
387
|
+
const content = contentLines[occurrence.contentLineIndex]?.content;
|
|
388
|
+
if (content === undefined) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (content.startsWith(closeToken, occurrence.column)) {
|
|
392
|
+
classified.push({ kind: "close" });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const matchedOpen = openTokens.some((token) => content.startsWith(token, occurrence.column));
|
|
396
|
+
if (matchedOpen) {
|
|
397
|
+
classified.push({ kind: "open" });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (classified.length === 0) {
|
|
401
|
+
return 0;
|
|
402
|
+
}
|
|
403
|
+
const walk0 = walkAsymmetricOccurrences(classified, 0);
|
|
404
|
+
const walk1 = walkAsymmetricOccurrences(classified, 1);
|
|
405
|
+
if (walk0.conflicts < walk1.conflicts) {
|
|
406
|
+
return walk0.endDepth;
|
|
407
|
+
}
|
|
408
|
+
if (walk1.conflicts < walk0.conflicts) {
|
|
409
|
+
return walk1.endDepth;
|
|
410
|
+
}
|
|
411
|
+
return Math.min(walk0.endDepth, walk1.endDepth);
|
|
412
|
+
}
|
|
413
|
+
function appendClosingTokensToLastContentLine(lines, closeToken, count) {
|
|
414
|
+
if (count <= 0) {
|
|
415
|
+
return [...lines];
|
|
416
|
+
}
|
|
417
|
+
const lastContentLineIndex = [...lines].findLastIndex(isDiffContentLine);
|
|
418
|
+
if (lastContentLineIndex === -1) {
|
|
419
|
+
return [...lines];
|
|
420
|
+
}
|
|
421
|
+
const closingSuffix = Array.from({ length: count }, () => closeToken).join(" ");
|
|
422
|
+
return lines.map((line, index) => {
|
|
423
|
+
if (index !== lastContentLineIndex || !isDiffContentLine(line)) {
|
|
424
|
+
return line;
|
|
425
|
+
}
|
|
426
|
+
return `${line} ${closingSuffix}`;
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
function prependOpeningTokenToFirstContentLine(lines, openToken, beforeHunkLineIndex) {
|
|
430
|
+
// Prefer a blank/whitespace content line to avoid creating a fake info string
|
|
431
|
+
// (e.g. "``` handler() {" makes tree-sitter think "handler()" is a language).
|
|
432
|
+
// Only search among lines BEFORE the first fence so the synthetic opener
|
|
433
|
+
// appears before the boundary closer (they need to pair).
|
|
434
|
+
const firstContentLineIndex = lines.findIndex(isDiffContentLine);
|
|
435
|
+
if (firstContentLineIndex === -1) {
|
|
436
|
+
return [...lines];
|
|
437
|
+
}
|
|
438
|
+
const searchEnd = beforeHunkLineIndex ?? lines.length;
|
|
439
|
+
let targetIndex = firstContentLineIndex;
|
|
440
|
+
for (let i = firstContentLineIndex; i < searchEnd; i++) {
|
|
441
|
+
const line = lines[i];
|
|
442
|
+
if (!isDiffContentLine(line))
|
|
443
|
+
continue;
|
|
444
|
+
if (line.slice(1).trim() === "") {
|
|
445
|
+
targetIndex = i;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return lines.map((line, index) => {
|
|
450
|
+
if (index !== targetIndex || !isDiffContentLine(line)) {
|
|
451
|
+
return line;
|
|
452
|
+
}
|
|
453
|
+
const prefix = line[0] ?? "";
|
|
454
|
+
const content = line.slice(1);
|
|
455
|
+
return `${prefix}${openToken} ${content}`;
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Balance paired delimiters in a unified diff patch for correct syntax
|
|
460
|
+
* highlighting.
|
|
461
|
+
*
|
|
462
|
+
* Pass 1 (tokenize): for each hunk, extract content lines and count
|
|
463
|
+
* delimiter occurrences.
|
|
464
|
+
*
|
|
465
|
+
* Pass 2 (repair): if a hunk has an odd count for any symmetric delimiter,
|
|
466
|
+
* classify the unmatched boundary token as a likely opener or closer and add
|
|
467
|
+
* the missing paired token on an existing content line.
|
|
468
|
+
*
|
|
469
|
+
* Pass 3 (hunk isolation): if a hunk leaves an asymmetric delimiter open,
|
|
470
|
+
* append its closing token to the last content line so the next hunk starts
|
|
471
|
+
* from a clean parser state.
|
|
472
|
+
*/
|
|
473
|
+
export function balanceDelimiters(rawDiff, filetype) {
|
|
474
|
+
if (!filetype)
|
|
475
|
+
return rawDiff;
|
|
476
|
+
const rules = LANGUAGE_DELIMITERS[filetype];
|
|
477
|
+
if (!rules)
|
|
478
|
+
return rawDiff;
|
|
479
|
+
const lines = rawDiff.split("\n");
|
|
480
|
+
const fileHeader = [];
|
|
481
|
+
const hunks = [];
|
|
482
|
+
for (const line of lines) {
|
|
483
|
+
if (line.startsWith("@@")) {
|
|
484
|
+
hunks.push({ header: line, lines: [] });
|
|
485
|
+
}
|
|
486
|
+
else if (hunks.length > 0) {
|
|
487
|
+
hunks[hunks.length - 1].lines.push(line);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
fileHeader.push(line);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (hunks.length === 0)
|
|
494
|
+
return rawDiff;
|
|
495
|
+
const result = [...fileHeader];
|
|
496
|
+
for (const hunk of hunks) {
|
|
497
|
+
const contentLines = getContentLines(hunk.lines);
|
|
498
|
+
let repairedLines = hunk.lines;
|
|
499
|
+
for (const rule of rules) {
|
|
500
|
+
if (!isSymmetricRule(rule)) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
// Contextual fence pairing (markdown code fences): uses sequential
|
|
504
|
+
// open/close tracking instead of simple odd/even parity.
|
|
505
|
+
if (rule.classifyFence) {
|
|
506
|
+
repairedLines = repairContextualFences(contentLines, repairedLines, rule);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const occurrences = findDelimiterOccurrences(contentLines, rule.token);
|
|
510
|
+
if (occurrences.length % 2 === 0) {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const first = occurrences[0];
|
|
514
|
+
const last = occurrences[occurrences.length - 1];
|
|
515
|
+
if (!first || !last) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const firstBoundary = classifyOccurrence(contentLines, first, rule.token);
|
|
519
|
+
const lastBoundary = classifyOccurrence(contentLines, last, rule.token);
|
|
520
|
+
if (firstBoundary === "close") {
|
|
521
|
+
repairedLines = prependOpeningTokenToFirstContentLine(repairedLines, rule.token, first.hunkLineIndex);
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
if (lastBoundary === "open") {
|
|
525
|
+
repairedLines = appendClosingTokensToLastContentLine(repairedLines, rule.token, 1);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
for (const rule of rules) {
|
|
530
|
+
const unclosedCount = getUnclosedTokenCount(repairedLines, rule);
|
|
531
|
+
if (unclosedCount > 0) {
|
|
532
|
+
repairedLines = appendClosingTokensToLastContentLine(repairedLines, getRuleCloseToken(rule), unclosedCount);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
result.push(hunk.header);
|
|
536
|
+
result.push(...repairedLines);
|
|
537
|
+
}
|
|
538
|
+
return result.join("\n");
|
|
539
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"balance-delimiters.test.d.ts","sourceRoot":"","sources":["../src/balance-delimiters.test.ts"],"names":[],"mappings":""}
|