@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 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.1",
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 to multi-line ranges (a construct rewrite) on purpose: a single-line
456
- * `replace N.=N` expanding into several lines is an *expansion* where every
457
- * payload line is intentional new content, so a payload line that happens to
458
- * equal a neighbor stays only a genuine block rewrite retypes a boundary
459
- * keeper by mistake. The dropped lines must be delimiter-neutral so removing the
460
- * duplicate keeps the already-balanced result balanced, and must not consume the
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