@oh-my-pi/hashline 17.3.7 → 17.4.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/CHANGELOG.md +11 -0
- package/dist/types/messages.d.ts +7 -0
- package/dist/types/snapshots.d.ts +1 -1
- package/dist/types/syntax.d.ts +10 -0
- package/package.json +3 -3
- package/src/apply.ts +162 -21
- package/src/messages.ts +14 -0
- package/src/snapshots.ts +6 -2
- package/src/syntax.ts +31 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.4.0] - 2026-08-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added an opener-escape landing correction for insertions anchored on a construct's opening line to place shallower sibling constructs after the enclosing block rather than splitting the opener from its body.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed an issue where single-line replacements echoing attributes or decorators (such as `#[napi]` or `@Injectable()`) could lead to silently duplicated annotations.
|
|
14
|
+
- Increased the default snapshot-store path capacity from 30 to 256 to prevent early tags in wide sessions from aging out and triggering misleading "hash is not from this session" errors.
|
|
15
|
+
|
|
5
16
|
## [17.3.3] - 2026-08-14
|
|
6
17
|
|
|
7
18
|
### Fixed
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -170,6 +170,13 @@ export declare function afterInsertLandingShiftWarning(anchorLine: number, landi
|
|
|
170
170
|
* the block's body".
|
|
171
171
|
*/
|
|
172
172
|
export declare function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string;
|
|
173
|
+
/**
|
|
174
|
+
* Plain `PUT >N:` anchored on a block-opener line with a shallower construct
|
|
175
|
+
* body: the landing was moved past the whole block — anchoring on an opener
|
|
176
|
+
* places the body between the opener and its first statement, a position a
|
|
177
|
+
* body at the opener's depth or above never intends.
|
|
178
|
+
*/
|
|
179
|
+
export declare function afterInsertOpenerEscapeWarning(anchorLine: number, landingLine: number): string;
|
|
173
180
|
/** `Recovery`: an external write matched a cached snapshot. */
|
|
174
181
|
export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
|
175
182
|
/** `Recovery`: a prior in-session edit advanced the hash. */
|
|
@@ -76,7 +76,7 @@ export declare abstract class SnapshotStore {
|
|
|
76
76
|
abstract clear(): void;
|
|
77
77
|
}
|
|
78
78
|
export interface InMemorySnapshotStoreOptions {
|
|
79
|
-
/** Maximum number of distinct paths tracked at once (default
|
|
79
|
+
/** Maximum number of distinct paths tracked at once (default 256). LRU eviction. */
|
|
80
80
|
maxPaths?: number;
|
|
81
81
|
/** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
|
|
82
82
|
maxVersionsPerPath?: number;
|
package/dist/types/syntax.d.ts
CHANGED
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
* language yields no structural proof, so only evidence-complete textual
|
|
9
9
|
* normalization remains available.
|
|
10
10
|
*/
|
|
11
|
+
import { type NodeSpan } from "@oh-my-pi/pi-natives";
|
|
12
|
+
export type { NodeSpan };
|
|
13
|
+
/**
|
|
14
|
+
* Named-node chain (innermost-first) containing `line`, excluding the file
|
|
15
|
+
* root: single-line nodes beginning on the line (attributes, decorators)
|
|
16
|
+
* first, then every enclosing construct. Empty when the language is unknown,
|
|
17
|
+
* the line is blank, or the source cannot parse — callers treat empty as
|
|
18
|
+
* "no structural evidence", never as evidence about the line.
|
|
19
|
+
*/
|
|
20
|
+
export declare function nodeChain(lines: readonly string[], path: string, line: number): readonly NodeSpan[];
|
|
11
21
|
/** Syntactic node boundaries outside a visible source range. */
|
|
12
22
|
export declare function enclosingBoundaries(lines: readonly string[], path: string, startLine: number, endLine: number): readonly number[];
|
|
13
23
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "17.
|
|
4
|
+
"version": "17.4.0",
|
|
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",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"fmt": "biome format --write ."
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@oh-my-pi/pi-natives": "17.
|
|
37
|
-
"@oh-my-pi/pi-utils": "17.
|
|
36
|
+
"@oh-my-pi/pi-natives": "17.4.0",
|
|
37
|
+
"@oh-my-pi/pi-utils": "17.4.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/bun": "^1.3.14"
|
package/src/apply.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { resolveClipboardEdits } from "./clipboard";
|
|
13
13
|
import {
|
|
14
14
|
afterInsertLandingShiftWarning,
|
|
15
|
+
afterInsertOpenerEscapeWarning,
|
|
15
16
|
ambiguousBoundaryEchoMessage,
|
|
16
17
|
ambiguousBoundaryPlacementMessage,
|
|
17
18
|
blockInsertLandingShiftWarning,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
UNRESOLVED_BLOCK_INTERNAL,
|
|
23
24
|
UNRESOLVED_CLIPBOARD_INTERNAL,
|
|
24
25
|
} from "./messages";
|
|
25
|
-
import { enclosingBoundaries, parsesCleanly } from "./syntax";
|
|
26
|
+
import { enclosingBoundaries, nodeChain, parsesCleanly } from "./syntax";
|
|
26
27
|
import { cloneCursor } from "./tokenizer";
|
|
27
28
|
import type { Anchor, ApplyResult, Clipboard, Cursor, Edit } from "./types";
|
|
28
29
|
|
|
@@ -153,6 +154,51 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
153
154
|
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
|
154
155
|
export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
|
155
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Grammar node kinds for attribute/decorator/annotation rows, per bundled
|
|
159
|
+
* tree-sitter grammar. Statements may repeat verbatim on adjacent lines by
|
|
160
|
+
* intent, but annotations on one item never do — an exact adjacent copy is a
|
|
161
|
+
* boundary echo. This classification is the extra evidence that lets
|
|
162
|
+
* one-sided echo normalization act on single-line ranges (the
|
|
163
|
+
* doc-restoration incident: a `PUT N.=N` landed one line high and its body
|
|
164
|
+
* restated the `#[napi]` surviving just below the range, duplicating the
|
|
165
|
+
* attribute — a result that PARSES, attributes being repeatable syntax, so
|
|
166
|
+
* neither the probe advisory nor the variant search could catch it).
|
|
167
|
+
*
|
|
168
|
+
* Kinds are grammar-blessed names, verified against the bundled parsers —
|
|
169
|
+
* not lexical guesses; `@media` in CSS or `@x` inside a string never
|
|
170
|
+
* classify. Extend per grammar as needed.
|
|
171
|
+
*/
|
|
172
|
+
const ANNOTATION_NODE_KINDS: Record<string, true> = {
|
|
173
|
+
attribute_item: true, // rust `#[...]`
|
|
174
|
+
inner_attribute_item: true, // rust `#![...]`
|
|
175
|
+
decorator: true, // typescript/tsx/javascript/python
|
|
176
|
+
annotation: true, // kotlin, java `@Foo(...)`
|
|
177
|
+
marker_annotation: true, // java `@Override`
|
|
178
|
+
attribute_list: true, // c# `[Fact]`
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** File line `line` is exactly a single-line annotation node. */
|
|
182
|
+
function isAnnotationLine(fileLines: readonly string[], path: string, line: number): boolean {
|
|
183
|
+
return nodeChain(fileLines, path, line).some(
|
|
184
|
+
node => node.startLine === line && node.endLine === line && ANNOTATION_NODE_KINDS[node.kind] === true,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Every file line in the inclusive range is an annotation node. */
|
|
189
|
+
function isAnnotationEchoRun(
|
|
190
|
+
fileLines: readonly string[],
|
|
191
|
+
path: string | undefined,
|
|
192
|
+
first: number,
|
|
193
|
+
last: number,
|
|
194
|
+
): boolean {
|
|
195
|
+
if (path === undefined) return false;
|
|
196
|
+
for (let line = first; line <= last; line++) {
|
|
197
|
+
if (!isAnnotationLine(fileLines, path, line)) return false;
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
156
202
|
interface ReplacementGroup {
|
|
157
203
|
/** Positions in the edit array of the payload inserts, in payload order. */
|
|
158
204
|
insertIndices: number[];
|
|
@@ -325,18 +371,22 @@ interface TextualBoundaryNormalization {
|
|
|
325
371
|
}
|
|
326
372
|
|
|
327
373
|
/**
|
|
328
|
-
* Normalize exact boundary echoes
|
|
374
|
+
* Normalize exact boundary echoes.
|
|
329
375
|
*
|
|
330
376
|
* Two-sided echoes are removed when stripping both copies leaves one payload
|
|
331
|
-
* row per deleted range line. One-sided echoes
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
377
|
+
* row per deleted range line. One-sided echoes are removed when the remaining
|
|
378
|
+
* payload still covers the full range — on multi-line ranges from line
|
|
379
|
+
* equality alone, on single-line ranges only when every echoed row is a
|
|
380
|
+
* grammar-classified annotation ({@link ANNOTATION_NODE_KINDS}), where an
|
|
381
|
+
* adjacent duplicate is never intentional. An under-filled one-sided echo is
|
|
382
|
+
* recorded as ambiguous so the syntax-probe search gets first chance to
|
|
383
|
+
* resolve it, then rejected rather than silently dropping unique range
|
|
384
|
+
* content.
|
|
336
385
|
*/
|
|
337
386
|
function normalizeTextualBoundaryEchoes(
|
|
338
387
|
edits: readonly AppliedEdit[],
|
|
339
388
|
fileLines: readonly string[],
|
|
389
|
+
path: string | undefined,
|
|
340
390
|
): TextualBoundaryNormalization {
|
|
341
391
|
const out: AppliedEdit[] = [];
|
|
342
392
|
const warnings: string[] = [];
|
|
@@ -361,7 +411,10 @@ function normalizeTextualBoundaryEchoes(
|
|
|
361
411
|
dropLeading = leading;
|
|
362
412
|
dropTrailing = trailing;
|
|
363
413
|
}
|
|
364
|
-
} else if (
|
|
414
|
+
} else if (
|
|
415
|
+
leading > 0 &&
|
|
416
|
+
(rangeLength > 1 || isAnnotationEchoRun(fileLines, path, group.startLine - leading, group.startLine - 1))
|
|
417
|
+
) {
|
|
365
418
|
if (group.payload.length - leading >= rangeLength) {
|
|
366
419
|
dropLeading = leading;
|
|
367
420
|
} else {
|
|
@@ -372,7 +425,10 @@ function normalizeTextualBoundaryEchoes(
|
|
|
372
425
|
count: leading,
|
|
373
426
|
});
|
|
374
427
|
}
|
|
375
|
-
} else if (
|
|
428
|
+
} else if (
|
|
429
|
+
trailing > 0 &&
|
|
430
|
+
(rangeLength > 1 || isAnnotationEchoRun(fileLines, path, group.endLine + 1, group.endLine + trailing))
|
|
431
|
+
) {
|
|
376
432
|
if (group.payload.length - trailing >= rangeLength) {
|
|
377
433
|
dropTrailing = trailing;
|
|
378
434
|
} else {
|
|
@@ -947,6 +1003,37 @@ function resolveShiftedLanding(
|
|
|
947
1003
|
return landing === group.anchor ? undefined : { line: landing, crossed };
|
|
948
1004
|
}
|
|
949
1005
|
|
|
1006
|
+
/**
|
|
1007
|
+
* Body shape required for an opener-escape relocation: the rows tile into
|
|
1008
|
+
* leading single-line nodes (comments, attributes) followed by one multi-line
|
|
1009
|
+
* construct reaching the last content row — i.e. the body parses standalone
|
|
1010
|
+
* as one self-contained `mod`/`fn`/`class` that can be moved past the block
|
|
1011
|
+
* it was mis-anchored into without re-parenting anything. Bare statements and
|
|
1012
|
+
* multi-statement bodies fail it and stay literal: an under-indented one-line
|
|
1013
|
+
* body more likely names the inside of the block.
|
|
1014
|
+
*/
|
|
1015
|
+
function bodyIsRelocatableConstruct(rows: readonly string[], path: string): boolean {
|
|
1016
|
+
let last = rows.length;
|
|
1017
|
+
while (last > 0 && !hasNonWhitespace(rows[last - 1])) last--;
|
|
1018
|
+
if (last === 0) return false;
|
|
1019
|
+
let line = 1;
|
|
1020
|
+
while (line <= last) {
|
|
1021
|
+
if (!hasNonWhitespace(rows[line - 1])) {
|
|
1022
|
+
line++;
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
const spans = nodeChain(rows, path, line);
|
|
1026
|
+
let end = 0;
|
|
1027
|
+
for (const span of spans) {
|
|
1028
|
+
if (span.startLine === line && span.endLine > end) end = span.endLine;
|
|
1029
|
+
}
|
|
1030
|
+
if (end === 0) return false; // no node begins here — body does not parse as items
|
|
1031
|
+
if (end >= last) return end > line; // final node must be one multi-line construct
|
|
1032
|
+
line = end + 1;
|
|
1033
|
+
}
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
950
1037
|
/**
|
|
951
1038
|
* Resolve where a block-lowered after-insert anchored on the block's closing
|
|
952
1039
|
* line should land given a body depth `target` deeper than that closer: just
|
|
@@ -990,14 +1077,17 @@ function resolveInwardLanding(
|
|
|
990
1077
|
/**
|
|
991
1078
|
* Slide mis-anchored after-insert hunks to the depth their body indentation
|
|
992
1079
|
* claims: outward past the structural closer lines that follow the anchor
|
|
993
|
-
* when the body is shallower
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
1080
|
+
* when the body is shallower; for plain inserts anchored on a block opener,
|
|
1081
|
+
* past the whole block when the body is a balanced construct claiming a
|
|
1082
|
+
* depth strictly above the opener (syntax-probe verified via `path`); or — for
|
|
1083
|
+
* `insert_after_block N:` lowerings — inward across the block's trailing
|
|
1084
|
+
* closers when the body is deeper than the block's closing line. Returns the
|
|
1085
|
+
* corrected edit list plus one warning per shifted hunk.
|
|
997
1086
|
*/
|
|
998
1087
|
function repairAfterInsertLandings(
|
|
999
1088
|
edits: readonly AppliedEdit[],
|
|
1000
1089
|
fileLines: readonly string[],
|
|
1090
|
+
path: string | undefined,
|
|
1001
1091
|
): { edits: readonly AppliedEdit[]; warnings: string[] } {
|
|
1002
1092
|
// Group plain (non-replacement) after-anchor inserts per authored hunk:
|
|
1003
1093
|
// rows of one hunk share the anchor line and the patch header line.
|
|
@@ -1039,7 +1129,59 @@ function repairAfterInsertLandings(
|
|
|
1039
1129
|
warnings.push(afterInsertLandingShiftWarning(group.anchor, outward.line, outward.crossed));
|
|
1040
1130
|
continue;
|
|
1041
1131
|
}
|
|
1042
|
-
if (group.blockStart === undefined)
|
|
1132
|
+
if (group.blockStart === undefined) {
|
|
1133
|
+
// Opener-escape: `PUT >N:` anchored on a line that OPENS a construct,
|
|
1134
|
+
// with a self-contained construct body claiming a column depth
|
|
1135
|
+
// strictly above the opener — a landing between the opener and its
|
|
1136
|
+
// first statement, which no such body can intend, yet one that can
|
|
1137
|
+
// parse (items are legal inside Rust fn bodies). Candidates are the
|
|
1138
|
+
// enclosing constructs' end lines from the node chain, innermost
|
|
1139
|
+
// first, kept only when the construct's own opening depth sits at or
|
|
1140
|
+
// above the body's claim (the body could be its sibling); the first
|
|
1141
|
+
// candidate whose relocated result passes the syntax probe wins.
|
|
1142
|
+
// Equal-depth bodies stay literal, matching the outward shift.
|
|
1143
|
+
if (path === undefined) continue;
|
|
1144
|
+
const anchorText = fileLines[group.anchor - 1] ?? "";
|
|
1145
|
+
const targetCols = indentColumns(target);
|
|
1146
|
+
if (targetCols >= indentColumns(anchorText)) continue;
|
|
1147
|
+
const chain = nodeChain(fileLines, path, group.anchor);
|
|
1148
|
+
if (!chain.some(node => node.startLine === group.anchor && node.endLine > group.anchor)) continue;
|
|
1149
|
+
const rows = group.members.map(idx => insertEditAt(edits, idx).text);
|
|
1150
|
+
if (!bodyIsRelocatableConstruct(rows, path)) continue;
|
|
1151
|
+
const candidates = [
|
|
1152
|
+
...new Set(
|
|
1153
|
+
chain
|
|
1154
|
+
.filter(
|
|
1155
|
+
node =>
|
|
1156
|
+
node.endLine > group.anchor && indentColumns(fileLines[node.startLine - 1] ?? "") <= targetCols,
|
|
1157
|
+
)
|
|
1158
|
+
.map(node => node.endLine),
|
|
1159
|
+
),
|
|
1160
|
+
].sort((a, b) => a - b);
|
|
1161
|
+
for (const landing of candidates) {
|
|
1162
|
+
// Never relocate across another hunk's target; farther
|
|
1163
|
+
// candidates cross the same line, so stop outright.
|
|
1164
|
+
let blocked = false;
|
|
1165
|
+
for (const targeted of targetedLines) {
|
|
1166
|
+
if (targeted > group.anchor && targeted <= landing) {
|
|
1167
|
+
blocked = true;
|
|
1168
|
+
break;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (blocked) break;
|
|
1172
|
+
const trial = [...(out ?? edits)];
|
|
1173
|
+
for (const idx of group.members) {
|
|
1174
|
+
const edit = insertEditAt(trial, idx);
|
|
1175
|
+
trial[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line: landing } } };
|
|
1176
|
+
}
|
|
1177
|
+
if (parsesCleanly(path, materializeEdits(fileLines, trial).text)) {
|
|
1178
|
+
out = trial;
|
|
1179
|
+
warnings.push(afterInsertOpenerEscapeWarning(group.anchor, landing));
|
|
1180
|
+
break;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1043
1185
|
const inward = resolveInwardLanding(group, target, group.blockStart, fileLines, targetedLines);
|
|
1044
1186
|
if (inward === undefined) continue;
|
|
1045
1187
|
retarget(group, inward);
|
|
@@ -1070,7 +1212,6 @@ export interface ApplyEditsOptions {
|
|
|
1070
1212
|
interface Materialized {
|
|
1071
1213
|
text: string;
|
|
1072
1214
|
firstChangedLine: number | undefined;
|
|
1073
|
-
warnings: string[];
|
|
1074
1215
|
}
|
|
1075
1216
|
|
|
1076
1217
|
/**
|
|
@@ -1080,7 +1221,6 @@ interface Materialized {
|
|
|
1080
1221
|
* veto.
|
|
1081
1222
|
*/
|
|
1082
1223
|
function materializeEdits(originalLines: readonly string[], edits: readonly AppliedEdit[]): Materialized {
|
|
1083
|
-
const { edits: landed, warnings } = repairAfterInsertLandings(edits, originalLines);
|
|
1084
1224
|
const fileLines = [...originalLines];
|
|
1085
1225
|
const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
|
|
1086
1226
|
|
|
@@ -1093,7 +1233,7 @@ function materializeEdits(originalLines: readonly string[], edits: readonly Appl
|
|
|
1093
1233
|
const bofLines: string[] = [];
|
|
1094
1234
|
const eofLines: string[] = [];
|
|
1095
1235
|
const anchorEdits: IndexedEdit[] = [];
|
|
1096
|
-
|
|
1236
|
+
edits.forEach((edit, idx) => {
|
|
1097
1237
|
if (edit.kind === "insert" && edit.cursor.kind === "bof") {
|
|
1098
1238
|
bofLines.push(edit.text);
|
|
1099
1239
|
} else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
|
|
@@ -1157,7 +1297,7 @@ function materializeEdits(originalLines: readonly string[], edits: readonly Appl
|
|
|
1157
1297
|
const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
|
|
1158
1298
|
if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
|
|
1159
1299
|
|
|
1160
|
-
return { text: fileLines.join("\n"), firstChangedLine
|
|
1300
|
+
return { text: fileLines.join("\n"), firstChangedLine };
|
|
1161
1301
|
}
|
|
1162
1302
|
|
|
1163
1303
|
/**
|
|
@@ -1201,13 +1341,14 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
|
|
|
1201
1341
|
);
|
|
1202
1342
|
validateLineBounds(targetEdits, fileLines);
|
|
1203
1343
|
const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
|
|
1204
|
-
const
|
|
1205
|
-
const
|
|
1344
|
+
const landed = repairAfterInsertLandings(targetEdits, fileLines, options.path);
|
|
1345
|
+
const normalized = normalizeTextualBoundaryEchoes(landed.edits, fileLines, options.path);
|
|
1346
|
+
const leading = [...clipboardWarnings, ...indentationWarnings, ...landed.warnings, ...normalized.warnings];
|
|
1206
1347
|
const authoredResult = materializeEdits(fileLines, normalized.edits);
|
|
1207
1348
|
const baselineParses = parsesCleanly(options.path, text);
|
|
1208
1349
|
const authoredParses = parsesCleanly(options.path, authoredResult.text);
|
|
1209
1350
|
const finish = (result: Materialized, warnings: string[]): ApplyResult => {
|
|
1210
|
-
const merged = [...warnings
|
|
1351
|
+
const merged = [...warnings];
|
|
1211
1352
|
// Post-apply syntax advisory: the result stopped parsing while the
|
|
1212
1353
|
// pre-edit text parsed, so this patch demonstrably introduced the
|
|
1213
1354
|
// error. Catches misplacements no boundary variant can explain.
|
package/src/messages.ts
CHANGED
|
@@ -459,6 +459,20 @@ export function blockInsertLandingShiftWarning(blockStart: number, closerLine: n
|
|
|
459
459
|
return `PUT >${blockStart}*: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`PUT >N*\` lands AFTER the block at sibling depth — if inside was intended, use plain \`PUT >${closerLine}:\`.`;
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Plain `PUT >N:` anchored on a block-opener line with a shallower construct
|
|
464
|
+
* body: the landing was moved past the whole block — anchoring on an opener
|
|
465
|
+
* places the body between the opener and its first statement, a position a
|
|
466
|
+
* body at the opener's depth or above never intends.
|
|
467
|
+
*/
|
|
468
|
+
export function afterInsertOpenerEscapeWarning(anchorLine: number, landingLine: number): string {
|
|
469
|
+
return (
|
|
470
|
+
`PUT >${anchorLine}: line ${anchorLine} opens a block, and the body's indentation claims a position ` +
|
|
471
|
+
`outside it, so the body was landed after line ${landingLine} (verified by the syntax probe). ` +
|
|
472
|
+
`To insert after a whole construct, anchor on its closing line or use \`PUT >N*:\`.`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
462
476
|
/** `Recovery`: an external write matched a cached snapshot. */
|
|
463
477
|
export const RECOVERY_EXTERNAL_WARNING =
|
|
464
478
|
"Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
package/src/snapshots.ts
CHANGED
|
@@ -111,7 +111,11 @@ export abstract class SnapshotStore {
|
|
|
111
111
|
abstract clear(): void;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
// Wide sessions routinely touch far more than a few dozen files; evicting a
|
|
115
|
+
// path downgrades a genuinely in-session tag to the misleading "hash is not
|
|
116
|
+
// from this session" rejection. Retention is still bounded by
|
|
117
|
+
// DEFAULT_MAX_TOTAL_BYTES, so a high path count costs little.
|
|
118
|
+
const DEFAULT_MAX_PATHS = 256;
|
|
115
119
|
const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
|
|
116
120
|
/** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
|
|
117
121
|
const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
@@ -124,7 +128,7 @@ function mergeSeenLines(snapshot: Snapshot, lines: Iterable<number> | undefined)
|
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
export interface InMemorySnapshotStoreOptions {
|
|
127
|
-
/** Maximum number of distinct paths tracked at once (default
|
|
131
|
+
/** Maximum number of distinct paths tracked at once (default 256). LRU eviction. */
|
|
128
132
|
maxPaths?: number;
|
|
129
133
|
/** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
|
|
130
134
|
maxVersionsPerPath?: number;
|
package/src/syntax.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* normalization remains available.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { enclosingBlockBoundaries } from "@oh-my-pi/pi-natives";
|
|
12
|
+
import { enclosingBlockBoundaries, type NodeSpan, nodeChainAt } from "@oh-my-pi/pi-natives";
|
|
13
|
+
|
|
14
|
+
export type { NodeSpan };
|
|
13
15
|
|
|
14
16
|
/** Parse-result cache keyed by content hash + path; FIFO-bounded. */
|
|
15
17
|
const parseCache = new Map<string, boolean>();
|
|
@@ -17,6 +19,34 @@ const PARSE_CACHE_MAX = 256;
|
|
|
17
19
|
|
|
18
20
|
const boundaryCache = new Map<string, readonly number[]>();
|
|
19
21
|
|
|
22
|
+
const chainCache = new Map<string, readonly NodeSpan[]>();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Named-node chain (innermost-first) containing `line`, excluding the file
|
|
26
|
+
* root: single-line nodes beginning on the line (attributes, decorators)
|
|
27
|
+
* first, then every enclosing construct. Empty when the language is unknown,
|
|
28
|
+
* the line is blank, or the source cannot parse — callers treat empty as
|
|
29
|
+
* "no structural evidence", never as evidence about the line.
|
|
30
|
+
*/
|
|
31
|
+
export function nodeChain(lines: readonly string[], path: string, line: number): readonly NodeSpan[] {
|
|
32
|
+
const text = lines.join("\n");
|
|
33
|
+
const key = `${Bun.hash(text).toString(36)}:${text.length}:${path}:${line}`;
|
|
34
|
+
const cached = chainCache.get(key);
|
|
35
|
+
if (cached !== undefined) return cached;
|
|
36
|
+
let chain: readonly NodeSpan[];
|
|
37
|
+
try {
|
|
38
|
+
chain = nodeChainAt({ code: text, path, line }) ?? [];
|
|
39
|
+
} catch {
|
|
40
|
+
chain = [];
|
|
41
|
+
}
|
|
42
|
+
if (chainCache.size >= PARSE_CACHE_MAX) {
|
|
43
|
+
const oldest = chainCache.keys().next().value;
|
|
44
|
+
if (oldest !== undefined) chainCache.delete(oldest);
|
|
45
|
+
}
|
|
46
|
+
chainCache.set(key, chain);
|
|
47
|
+
return chain;
|
|
48
|
+
}
|
|
49
|
+
|
|
20
50
|
/** Syntactic node boundaries outside a visible source range. */
|
|
21
51
|
export function enclosingBoundaries(
|
|
22
52
|
lines: readonly string[],
|