@prometheus-ai/hashline 0.5.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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Re-number a unified diff that uses the `+<lineNum>|content` /
3
+ * `-<lineNum>|content` / ` <lineNum>|content` line format into a compact
4
+ * preview that anchors every line to its post-edit position. Added lines,
5
+ * removed lines, and context lines all end up with a hashline-style anchor
6
+ * so a follow-up edit can reuse them directly.
7
+ *
8
+ * This is intentionally decoupled from the diff producer: anything that
9
+ * emits the `<sign><lineNum>|<content>` shape works.
10
+ */
11
+ import type { CompactDiffOptions, CompactDiffPreview } from "./types";
12
+
13
+ export function buildCompactDiffPreview(diff: string, _options: CompactDiffOptions = {}): CompactDiffPreview {
14
+ const lines = diff.length === 0 ? [] : diff.split("\n");
15
+ let addedLines = 0;
16
+ let removedLines = 0;
17
+
18
+ // External diff producers number `+` lines with the post-edit line number,
19
+ // `-` lines with the pre-edit line number, and context lines with the
20
+ // pre-edit line number. To emit fresh line numbers usable for follow-up
21
+ // edits, convert context-line numbers to post-edit positions by tracking
22
+ // the running offset (added so far - removed so far) as we walk the diff.
23
+ const formatted = lines.map(line => {
24
+ const kind = line[0];
25
+ if (kind !== "+" && kind !== "-" && kind !== " ") return line;
26
+
27
+ const body = line.slice(1);
28
+ const sep = body.indexOf("|");
29
+ if (sep === -1) return line;
30
+
31
+ const lineNumber = Number.parseInt(body.slice(0, sep), 10);
32
+ const content = body.slice(sep + 1);
33
+
34
+ switch (kind) {
35
+ case "+":
36
+ addedLines++;
37
+ return `+${lineNumber}:${content}`;
38
+ case "-":
39
+ removedLines++;
40
+ return `-${lineNumber}:${content}`;
41
+ default: {
42
+ const newLineNumber = lineNumber + addedLines - removedLines;
43
+ return ` ${newLineNumber}:${content}`;
44
+ }
45
+ }
46
+ });
47
+
48
+ return { preview: formatted.join("\n"), addedLines, removedLines };
49
+ }
package/src/format.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Hashline format primitives: sigils, separators, regex fragments, and
3
+ * display helpers. These are the single source of truth for the parser, the
4
+ * tokenizer, the prompt, and the formal grammar.
5
+ */
6
+
7
+ import type { Cursor } from "./types";
8
+
9
+ /** File-section header delimiters: `[path#hash]`. */
10
+ export const HL_FILE_PREFIX = "[";
11
+ export const HL_FILE_SUFFIX = "]";
12
+
13
+ /** Payload sigil for literal body rows. */
14
+ export const HL_PAYLOAD_REPLACE = "+";
15
+
16
+ /** Hunk-header keyword for concrete line replacement. */
17
+ export const HL_REPLACE_KEYWORD = "replace";
18
+ /** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
19
+ export const HL_BLOCK_KEYWORD = "block";
20
+ /** Hunk-header keyword for concrete line deletion. */
21
+ export const HL_DELETE_KEYWORD = "delete";
22
+ /** Hunk-header keyword for insertion operations. */
23
+ export const HL_INSERT_KEYWORD = "insert";
24
+ /** Insert position keyword for inserting before a concrete line. */
25
+ export const HL_INSERT_BEFORE = "before";
26
+ /** Insert position keyword for inserting after a concrete line. */
27
+ export const HL_INSERT_AFTER = "after";
28
+ /** Insert position keyword for inserting at the start of the file. */
29
+ export const HL_INSERT_HEAD = "head";
30
+ /** Insert position keyword for inserting at the end of the file. */
31
+ export const HL_INSERT_TAIL = "tail";
32
+ /** Hunk-header terminator for body-bearing operations. */
33
+ export const HL_HEADER_COLON = ":";
34
+
35
+ /** Separator between a hashline file path and its opaque snapshot tag. */
36
+ export const HL_FILE_HASH_SEP = "#";
37
+
38
+ /** Separator between two line numbers in a range, e.g. `5..10`. */
39
+ export const HL_RANGE_SEP = "..";
40
+
41
+ /** Separator between a line number and displayed line content in hashline mode. */
42
+ export const HL_LINE_BODY_SEP = ":";
43
+
44
+ function regexEscape(str: string): string {
45
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ }
47
+
48
+ /** Bare positive line-number Lid (no decorations, no captures, no anchors). */
49
+ export const HL_LINE_RE_RAW = `[1-9]\\d*`;
50
+
51
+ /** Capture-group form of {@link HL_LINE_RE_RAW}. */
52
+ export const HL_LINE_CAPTURE_RE_RAW = `(${HL_LINE_RE_RAW})`;
53
+
54
+ /** Format a concrete replacement hunk header. */
55
+ export function formatReplaceHeader(start: number, end: number): string {
56
+ return `${HL_REPLACE_KEYWORD} ${start}${HL_RANGE_SEP}${end}${HL_HEADER_COLON}`;
57
+ }
58
+
59
+ /** Format a concrete deletion hunk header. */
60
+ export function formatDeleteHeader(start: number, end = start): string {
61
+ return start === end ? `${HL_DELETE_KEYWORD} ${start}` : `${HL_DELETE_KEYWORD} ${start}${HL_RANGE_SEP}${end}`;
62
+ }
63
+
64
+ /** Format an insertion hunk header for a cursor position. */
65
+ export function formatInsertHeader(cursor: Cursor): string {
66
+ switch (cursor.kind) {
67
+ case "before_anchor":
68
+ return `${HL_INSERT_KEYWORD} ${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
69
+ case "after_anchor":
70
+ return `${HL_INSERT_KEYWORD} ${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
71
+ case "bof":
72
+ return `${HL_INSERT_KEYWORD} ${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
73
+ case "eof":
74
+ return `${HL_INSERT_KEYWORD} ${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
75
+ }
76
+ }
77
+
78
+ /** Number of hex characters in a content-derived file-hash tag. */
79
+ export const HL_FILE_HASH_LENGTH = 4;
80
+ /** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */
81
+ export const HL_FILE_HASH_RE_RAW = `[0-9A-F]{${HL_FILE_HASH_LENGTH}}`;
82
+ /** Capture-group form of {@link HL_FILE_HASH_RE_RAW}. */
83
+ export const HL_FILE_HASH_CAPTURE_RE_RAW = `(${HL_FILE_HASH_RE_RAW})`;
84
+ /** Regex-escaped form of {@link HL_LINE_BODY_SEP}, safe for embedding inside a regex. */
85
+ export const HL_LINE_BODY_SEP_RE_RAW = regexEscape(HL_LINE_BODY_SEP);
86
+ /**
87
+ * Representative file-hash tags for use in user-facing error messages and
88
+ * prompt examples.
89
+ */
90
+ export const HL_FILE_HASH_EXAMPLES = ["1A2B", "3C4D", "9F3E"] as const;
91
+ /**
92
+ * Normalize text before hashing: trim trailing `[ \t\r]` from every line (and
93
+ * the final line) in a single pass so CRLF endings and display-trimmed lines
94
+ * do not invalidate a tag.
95
+ */
96
+ function normalizeFileHashText(text: string): string {
97
+ return text.replace(/[ \t\r]+(?=\n|$)/g, "");
98
+ }
99
+ /**
100
+ * Compute the content-derived hash tag carried by a hashline section header.
101
+ * The tag is a 4-hex fingerprint of the whole file's normalized text: any read
102
+ * of byte-identical content mints the same tag, and a follow-up edit anchored
103
+ * at any line validates whenever the live file still hashes to it.
104
+ */
105
+ export function computeFileHash(text: string): string {
106
+ const normalized = normalizeFileHashText(text);
107
+ const low16 = Bun.hash.xxHash32(normalized, 0) & 0xffff;
108
+ return low16.toString(16).padStart(HL_FILE_HASH_LENGTH, "0").toUpperCase();
109
+ }
110
+
111
+ /**
112
+ * Format a comma-separated list of example anchors with an optional line-number
113
+ * prefix, quoted for inclusion in error messages: `"160", "42", "7"`.
114
+ */
115
+ export function describeAnchorExamples(linePrefix = ""): string {
116
+ const examples = linePrefix ? [linePrefix, `${linePrefix.slice(0, -1) || "4"}2`, "7"] : ["160", "42", "7"];
117
+ return examples.map(e => `"${e}"`).join(", ");
118
+ }
119
+
120
+ /** Format a hashline section header for a file path and snapshot tag. */
121
+ export function formatHashlineHeader(filePath: string, fileHash: string): string {
122
+ return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}${HL_FILE_SUFFIX}`;
123
+ }
124
+
125
+ /** Formats a single numbered line as `LINE:TEXT`. */
126
+ export function formatNumberedLine(lineNumber: number, line: string): string {
127
+ return `${lineNumber}${HL_LINE_BODY_SEP}${line}`;
128
+ }
129
+
130
+ /** Format file text with hashline-mode line-number prefixes for display. */
131
+ export function formatNumberedLines(text: string, startLine = 1): string {
132
+ const lines = text.split("\n");
133
+ return lines.map((line, i) => formatNumberedLine(startLine + i, line)).join("\n");
134
+ }
package/src/fs.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Storage seam for the hashline patcher. {@link Filesystem} is intentionally
3
+ * minimal — `readText`, `writeText`, `exists` — so any backing store can be
4
+ * adapted: disk, memory, S3, an LSP text-document protocol, a Git tree, a
5
+ * VFS, etc.
6
+ *
7
+ * The patcher does its own BOM stripping and LF normalization between
8
+ * {@link Filesystem.readText} and {@link Filesystem.writeText}; the FS deals
9
+ * only in raw text strings.
10
+ */
11
+ import * as pathModule from "node:path";
12
+
13
+ /**
14
+ * Result returned by {@link Filesystem.writeText}. The patcher echoes back
15
+ * `text` so adapters that transform on serialization (e.g. notebooks) can
16
+ * report what actually landed on disk.
17
+ */
18
+ export interface WriteResult {
19
+ /** Final text that was persisted. May differ from the input if the FS transformed it. */
20
+ text: string;
21
+ }
22
+
23
+ /**
24
+ * ENOENT-like error thrown by {@link Filesystem.readText} when a path is
25
+ * missing. Carrying a `code` property keeps the contract compatible with
26
+ * `node:fs` callers that already check `err.code === "ENOENT"`.
27
+ */
28
+ export class NotFoundError extends Error {
29
+ readonly code = "ENOENT";
30
+
31
+ constructor(path: string, cause?: unknown) {
32
+ super(`File not found: ${path}`);
33
+ this.name = "NotFoundError";
34
+ if (cause !== undefined) (this as Error & { cause?: unknown }).cause = cause;
35
+ }
36
+ }
37
+
38
+ /** Type guard for {@link NotFoundError} and structurally-compatible errors. */
39
+ export function isNotFound(error: unknown): boolean {
40
+ if (error instanceof NotFoundError) return true;
41
+ if (error instanceof Error && (error as Error & { code?: string }).code === "ENOENT") return true;
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * Abstract storage backend the {@link Patcher} reads from and writes to.
47
+ * Subclass for new backends; the package ships {@link InMemoryFilesystem} and
48
+ * {@link NodeFilesystem} for the most common cases.
49
+ *
50
+ * Implementations work with raw text — the patcher handles BOM stripping and
51
+ * line-ending normalization itself. `readText` MUST throw {@link
52
+ * NotFoundError} (or any error for which {@link isNotFound} returns true)
53
+ * when the path doesn't exist; that's how the patcher detects a create-vs-
54
+ * update.
55
+ */
56
+ export abstract class Filesystem {
57
+ /** Read the file's full text content. Throw on missing file. */
58
+ abstract readText(path: string): Promise<string>;
59
+
60
+ /** Validate that `path` is writable before a prepared batch starts committing. */
61
+ async preflightWrite(_path: string): Promise<void> {}
62
+
63
+ /** Persist `content` at `path`. Returns the actual final text that was written. */
64
+ abstract writeText(path: string, content: string): Promise<WriteResult>;
65
+
66
+ /** Return true when the path exists and can be read. Default: probe via {@link readText}. */
67
+ async exists(path: string): Promise<boolean> {
68
+ try {
69
+ await this.readText(path);
70
+ return true;
71
+ } catch (error) {
72
+ if (isNotFound(error)) return false;
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Canonical path used as a key by external caches (e.g. snapshot
79
+ * stores). The default is identity; override to return an absolute or
80
+ * otherwise canonicalised path so producers and consumers of cached
81
+ * snapshots agree on the key without each having to redo the resolution.
82
+ */
83
+ canonicalPath(path: string): string {
84
+ return path;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * In-memory {@link Filesystem}. Useful for tests, sandboxes, dry-runs, and as
90
+ * a building block for stacked adapters (e.g. an LRU layer on top).
91
+ */
92
+ export class InMemoryFilesystem extends Filesystem {
93
+ #files = new Map<string, string>();
94
+
95
+ constructor(initial?: Iterable<readonly [string, string]>) {
96
+ super();
97
+ if (initial) {
98
+ for (const [path, content] of initial) this.#files.set(path, content);
99
+ }
100
+ }
101
+
102
+ async readText(path: string): Promise<string> {
103
+ const text = this.#files.get(path);
104
+ if (text === undefined) throw new NotFoundError(path);
105
+ return text;
106
+ }
107
+
108
+ async writeText(path: string, content: string): Promise<WriteResult> {
109
+ this.#files.set(path, content);
110
+ return { text: content };
111
+ }
112
+
113
+ async exists(path: string): Promise<boolean> {
114
+ return this.#files.has(path);
115
+ }
116
+
117
+ /** Synchronous helper for setting up fixtures without awaiting. */
118
+ set(path: string, content: string): void {
119
+ this.#files.set(path, content);
120
+ }
121
+
122
+ /** Synchronous helper for inspecting state without awaiting. */
123
+ get(path: string): string | undefined {
124
+ return this.#files.get(path);
125
+ }
126
+
127
+ /** Remove a single entry. Returns true when something was removed. */
128
+ delete(path: string): boolean {
129
+ return this.#files.delete(path);
130
+ }
131
+
132
+ /** Wipe all entries. */
133
+ clear(): void {
134
+ this.#files.clear();
135
+ }
136
+
137
+ /** Iterate `[path, content]` pairs. */
138
+ entries(): IterableIterator<[string, string]> {
139
+ return this.#files.entries();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Disk-backed {@link Filesystem} using Bun's file APIs. The default for CLI
145
+ * use. Paths are accepted as-is; callers responsible for any cwd or
146
+ * jail/sandbox resolution should wrap this with their own subclass.
147
+ */
148
+ export class NodeFilesystem extends Filesystem {
149
+ async readText(path: string): Promise<string> {
150
+ const file = Bun.file(path);
151
+ if (!(await file.exists())) throw new NotFoundError(path);
152
+ return file.text();
153
+ }
154
+
155
+ async writeText(path: string, content: string): Promise<WriteResult> {
156
+ await Bun.write(path, content);
157
+ return { text: content };
158
+ }
159
+
160
+ canonicalPath(path: string): string {
161
+ return pathModule.resolve(path);
162
+ }
163
+
164
+ async exists(path: string): Promise<boolean> {
165
+ return Bun.file(path).exists();
166
+ }
167
+ }
@@ -0,0 +1,25 @@
1
+ start: begin_patch file_patch+ end_patch
2
+ begin_patch: "*** Begin Patch" LF
3
+ end_patch: "*** End Patch" LF?
4
+
5
+ file_patch: file_header hunk+
6
+ file_header: "[" filename "#" file_hash "]" LF
7
+ file_hash: /[0-9A-F]{4}/
8
+ filename: /[^#\r\n]+/
9
+
10
+ hunk: replace_hunk | replace_block_hunk | insert_hunk | delete_hunk | delete_block_hunk
11
+ replace_hunk: replace_anchor LF emit_op*
12
+ replace_block_hunk: replace_block_anchor LF emit_op+
13
+ insert_hunk: insert_anchor LF emit_op+
14
+ delete_hunk: "delete " header_range LF
15
+ delete_block_hunk: "delete block " LID LF
16
+ replace_anchor: "replace " header_range ":"
17
+ replace_block_anchor: "replace block " LID ":"
18
+ insert_anchor: "insert " insert_pos ":"
19
+ insert_pos: "before " LID | "after " LID | "head" | "tail"
20
+ emit_op: "+" /(.*)/ LF
21
+
22
+ header_range: LID ".." LID
23
+ LID: /[1-9]\d*/
24
+
25
+ %import common.LF
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export * from "./apply";
2
+ export * from "./block";
3
+ export * from "./diff-preview";
4
+ export * from "./format";
5
+ export * from "./fs";
6
+ export * from "./input";
7
+ export * from "./messages";
8
+ export * from "./mismatch";
9
+ export * from "./normalize";
10
+ export * from "./parser";
11
+ export * from "./patcher";
12
+ export * from "./prefixes";
13
+ export * from "./recovery";
14
+ export * from "./snapshots";
15
+ export * from "./stream";
16
+ export * from "./tokenizer";
17
+ export * from "./types";