@oh-my-pi/hashline 16.0.1 → 16.0.3
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/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/apply.ts +108 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.0.2] - 2026-06-16
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Auto-repaired duplicated JSX/XML closing boundary lines at the end of single-line replacement expansions. ([#2705](https://github.com/can1357/oh-my-pi/issues/2705))
|
|
10
|
+
|
|
5
11
|
## [16.0.1] - 2026-06-15
|
|
6
12
|
|
|
7
13
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "16.0.
|
|
4
|
+
"version": "16.0.3",
|
|
5
5
|
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
package/src/apply.ts
CHANGED
|
@@ -130,6 +130,102 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
130
130
|
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
|
131
131
|
export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
|
132
132
|
|
|
133
|
+
/** A JSX/XML closing boundary that carries structure but no bracket tokens. */
|
|
134
|
+
const JSX_CLOSER_RE = /^\s*(?:<\/>|<\/[A-Za-z][\w.:-]*>|\/>)\s*[;,]?\s*$/;
|
|
135
|
+
const JSX_NAMED_CLOSER_RE = /^\s*<\/([A-Za-z][\w.:-]*)>\s*[;,]?\s*$/;
|
|
136
|
+
const JSX_FRAGMENT_CLOSER_RE = /^\s*<\/>\s*[;,]?\s*$/;
|
|
137
|
+
|
|
138
|
+
function isStructuralCloserLine(text: string): boolean {
|
|
139
|
+
return STRUCTURAL_CLOSER_RE.test(text) || JSX_CLOSER_RE.test(text);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function jsxCloserName(text: string): string | undefined {
|
|
143
|
+
if (JSX_FRAGMENT_CLOSER_RE.test(text)) return "";
|
|
144
|
+
const match = JSX_NAMED_CLOSER_RE.exec(text);
|
|
145
|
+
return match?.[1];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface JsxPayloadTag {
|
|
149
|
+
readonly name: string;
|
|
150
|
+
readonly closing: boolean;
|
|
151
|
+
readonly selfClosing: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isJsxTagStart(text: string, index: number): boolean {
|
|
155
|
+
const next = text[index + 1];
|
|
156
|
+
return next === ">" || next === "/" || (next >= "A" && next <= "Z") || (next >= "a" && next <= "z");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function findJsxTagEnd(text: string, start: number): number {
|
|
160
|
+
let quote: string | undefined;
|
|
161
|
+
let braces = 0;
|
|
162
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
163
|
+
const ch = text[i];
|
|
164
|
+
if (quote) {
|
|
165
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
166
|
+
i++;
|
|
167
|
+
} else if (ch === quote) {
|
|
168
|
+
quote = undefined;
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
173
|
+
quote = ch;
|
|
174
|
+
} else if (ch === "{") {
|
|
175
|
+
braces++;
|
|
176
|
+
} else if (ch === "}" && braces > 0) {
|
|
177
|
+
braces--;
|
|
178
|
+
} else if (ch === ">" && braces === 0) {
|
|
179
|
+
return i;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return -1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseJsxPayloadTag(raw: string): JsxPayloadTag | undefined {
|
|
186
|
+
if (raw === "<>") return { name: "", closing: false, selfClosing: false };
|
|
187
|
+
if (raw === "</>") return { name: "", closing: true, selfClosing: false };
|
|
188
|
+
const closing = raw.startsWith("</");
|
|
189
|
+
const nameStart = closing ? 2 : 1;
|
|
190
|
+
let nameEnd = nameStart;
|
|
191
|
+
while (nameEnd < raw.length && /[\w.:-]/.test(raw[nameEnd])) nameEnd++;
|
|
192
|
+
if (nameEnd === nameStart) return undefined;
|
|
193
|
+
return {
|
|
194
|
+
name: raw.slice(nameStart, nameEnd),
|
|
195
|
+
closing,
|
|
196
|
+
selfClosing: !closing && /\/>\s*$/.test(raw),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readJsxPayloadTags(text: string): JsxPayloadTag[] {
|
|
201
|
+
const tags: JsxPayloadTag[] = [];
|
|
202
|
+
for (let start = text.indexOf("<"); start >= 0; start = text.indexOf("<", start + 1)) {
|
|
203
|
+
if (!isJsxTagStart(text, start)) continue;
|
|
204
|
+
const end = findJsxTagEnd(text, start);
|
|
205
|
+
if (end < 0) break;
|
|
206
|
+
const tag = parseJsxPayloadTag(text.slice(start, end + 1));
|
|
207
|
+
if (tag) tags.push(tag);
|
|
208
|
+
start = end;
|
|
209
|
+
}
|
|
210
|
+
return tags;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function payloadHasJsxOpenerForEcho(payloadPrefix: readonly string[], echoLines: readonly string[]): boolean {
|
|
214
|
+
const openTags: string[] = [];
|
|
215
|
+
for (const tag of readJsxPayloadTags(payloadPrefix.join("\n"))) {
|
|
216
|
+
if (tag.closing) {
|
|
217
|
+
if (openTags[openTags.length - 1] === tag.name) openTags.pop();
|
|
218
|
+
} else if (!tag.selfClosing) {
|
|
219
|
+
openTags.push(tag.name);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const line of echoLines) {
|
|
223
|
+
const name = jsxCloserName(line);
|
|
224
|
+
if (name !== undefined && openTags.includes(name)) return true;
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
133
229
|
interface DelimiterBalance {
|
|
134
230
|
paren: number;
|
|
135
231
|
bracket: number;
|
|
@@ -452,19 +548,18 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
|
|
|
452
548
|
* are handled by {@link findBoundaryEcho}; delimiter-imbalanced one-sided echoes
|
|
453
549
|
* by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
|
|
454
550
|
*
|
|
455
|
-
* Scoped
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
* whole payload.
|
|
551
|
+
* Scoped broadly for multi-line ranges (a construct rewrite) because retouched
|
|
552
|
+
* neutral keepers are usually boundary mistakes there. Single-line expansions
|
|
553
|
+
* are riskier — ordinary duplicated statements may be intentional — so they are
|
|
554
|
+
* only repaired when the duplicated edge is a structural closer line that
|
|
555
|
+
* carries no delimiter-balance signal itself, such as a JSX `</section>` close.
|
|
556
|
+
* The dropped lines must keep the already-balanced result balanced, and must
|
|
557
|
+
* not consume the whole payload.
|
|
462
558
|
*/
|
|
463
559
|
function findOneSidedBoundaryEcho(
|
|
464
560
|
group: ReplacementGroup,
|
|
465
561
|
fileLines: readonly string[],
|
|
466
562
|
): { side: "leading" | "trailing"; count: number } | undefined {
|
|
467
|
-
if (group.deleteIndices.length <= 1) return undefined;
|
|
468
563
|
const leading = countDuplicateLeadingBoundaryLines(group, fileLines);
|
|
469
564
|
const trailing = countDuplicateTrailingBoundaryLines(group, fileLines);
|
|
470
565
|
if (leading > 0 === trailing > 0) return undefined;
|
|
@@ -474,6 +569,11 @@ function findOneSidedBoundaryEcho(
|
|
|
474
569
|
const echoLines =
|
|
475
570
|
side === "leading" ? group.payload.slice(0, count) : group.payload.slice(group.payload.length - count);
|
|
476
571
|
if (!balanceIsZero(computeDelimiterBalance(echoLines))) return undefined;
|
|
572
|
+
if (group.deleteIndices.length <= 1) {
|
|
573
|
+
if (side !== "trailing" || !echoLines.every(isStructuralCloserLine)) return undefined;
|
|
574
|
+
const payloadPrefix = group.payload.slice(0, group.payload.length - count);
|
|
575
|
+
if (payloadHasJsxOpenerForEcho(payloadPrefix, echoLines)) return undefined;
|
|
576
|
+
}
|
|
477
577
|
return { side, count };
|
|
478
578
|
}
|
|
479
579
|
|