apply-edit 0.1.0 → 0.2.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/README.md CHANGED
@@ -4,6 +4,8 @@ Apply the `oldString` → `newString` edits that language models produce, withou
4
4
 
5
5
  Coding agents ask a model for the exact text to replace. Models drift: wrong indentation, tabs for spaces, a trailing space, CRLF versus LF, typographic quotes, literal `\n` sequences. Exact matching then fails, and fuzzy matching quietly edits the wrong place. This package sits between the two: it tolerates the drift that can be resolved unambiguously, refuses everything else with a precise report, and never widens or relocates an edit.
6
6
 
7
+ It also reads the other shape models return edits in: SEARCH/REPLACE blocks, in the aider, Cline and Roo Code dialects.
8
+
7
9
  Zero runtime dependencies. Strings in, strings out; no filesystem access. ESM, CommonJS and TypeScript declarations. Node 22+ and browsers.
8
10
 
9
11
  ```sh
@@ -61,6 +63,39 @@ The rules that make it safe to use in a loop:
61
63
 
62
64
  `applyEdits(text, edits)` resolves every edit against the original text, refuses overlapping edits, and applies all of them or none. `findEdit(text, oldString)` locates without changing anything.
63
65
 
66
+ ## SEARCH/REPLACE blocks
67
+
68
+ Many agents prompt for edits as marker-delimited blocks instead of JSON. Three dialects are in wide use and they are not compatible: aider uses `<<<<<<< SEARCH` with the filename on a preceding line, Cline uses `------- SEARCH` / `+++++++ REPLACE`, and Roo Code adds `:start_line:` hints and backslash-escaped markers. `applyBlocks` reads all three and applies them through the same matcher.
69
+
70
+ ```js
71
+ import {applyBlocks} from 'apply-edit';
72
+
73
+ const payload = `------- SEARCH
74
+ const timeout = 500;
75
+ =======
76
+ const timeout = 2_000;
77
+ +++++++ REPLACE`;
78
+
79
+ const result = applyBlocks(file, payload);
80
+ result.dialect; // 'cline'
81
+ result.text; // the file with the edit applied
82
+ ```
83
+
84
+ The dialect is detected from the markers and can be pinned with `{dialect: 'roo'}`. A payload that names several files is refused unless you say which one you are editing:
85
+
86
+ ```js
87
+ applyBlocks(file, payload, {file: 'src/server.ts'});
88
+ ```
89
+
90
+ Everything the matcher does for a single edit applies here: indentation drift is re-indented to the file, CRLF and BOM are preserved, an ambiguous block is refused with its candidate locations, and a Roo `:start_line:` hint selects between otherwise identical candidates. An unterminated block is reported rather than half-applied. An empty SEARCH section replaces the whole file, as Cline defines it.
91
+
92
+ Use `parseBlocks` when you want the edits without applying them, for review or for routing across files:
93
+
94
+ ```js
95
+ const {dialect, edits, problems} = parseBlocks(message, {files: knownPaths});
96
+ // edits: [{file, oldString, newString, line?, endLine?, block: {line}}]
97
+ ```
98
+
64
99
  ## API
65
100
 
66
101
  ### `applyEdit(text, edit, options?)`
@@ -79,18 +114,36 @@ Returns `{ok: true, text, applied, lineEnding, bom}` or the first failure with i
79
114
 
80
115
  Returns `{ok: true, strategy, matches, lineEnding, bom}` or a failure.
81
116
 
117
+ ### `applyBlocks(text, blocks, options?)`
118
+
119
+ `blocks`: the model message containing SEARCH/REPLACE blocks. `options` accepts the `applyEdit` options plus `{dialect?: 'aider' | 'cline' | 'roo', file?: string, files?: string[]}`. `file` selects which file's blocks to apply and is the default for blocks without a filename header; `files` helps resolve a header against known paths.
120
+
121
+ Returns the `applyEdits` result with `dialect` added, or a failure whose `code` is one of the matcher codes or `parse` (with `problems`), `multiple-files` (with `files`), `no-blocks-for-file`.
122
+
123
+ ### `parseBlocks(text, options?)`
124
+
125
+ Returns `{dialect, edits, problems}` without touching any file text. Each edit is `{file, oldString, newString, line?, endLine?, block: {line}}`; each problem is `{line, reason, detail}` with `reason` `missing-divider` or `missing-terminator`.
126
+
127
+ ### `detectDialect(text)`
128
+
129
+ Returns `'aider'`, `'cline'`, `'roo'`, or `null` when the text contains no blocks.
130
+
82
131
  ## Limits
83
132
 
84
133
  - Escaped-sequence recovery is heuristic when the code itself contains literal backslash sequences; ambiguous inputs are refused rather than guessed.
85
134
  - Re-indentation maps replacement lines to matched lines by trimmed content and follows the previous anchor for new lines. Blocks that mix tabs and spaces within one line may keep the model's indentation; `reindented` and the diff tell you.
86
135
  - Matching is line-oriented above the exact tier. Reordered or paraphrased lines are misses, reported through `closest`.
87
136
  - This library does not read or write files, lock, or check that a file changed since it was read. Keep those in the tool.
88
- - Unified diffs, SEARCH/REPLACE block parsing and model-assisted correction are out of scope.
137
+ - Dialect detection is a convenience: aider and Roo Code share markers, so a payload that uses Roo escaping without any Roo-only construct reads as aider. Pin `dialect` when you control the prompt.
138
+ - Filename headers follow aider's heuristics and are only as good as what the model wrote; `parseBlocks` reports the name it took, and `applyBlocks` refuses a payload spanning several files rather than guessing.
139
+ - Unified diffs and model-assisted correction are out of scope.
89
140
 
90
141
  ## Why not the usual fallback ladder
91
142
 
92
143
  On a seeded corpus of 1,942 constructed edits over 12 real source files (drifted indentation, tabs, trailing whitespace, CRLF, typographic punctuation, escaped newlines, plus decoys, duplicates, stale blocks and non-contiguous input), this implementation applied 1,286 edits with the expected output and made 0 wrong-location or false applications. A transcription of a widely copied nine-replacer fallback ladder applied 116 edits wrongly (mostly stale blocks silently overwritten) and produced 302 outputs with the model's wrong indentation inserted verbatim; the closest published library made 144 to 333 wrong applications depending on its fuzz setting. Method and numbers are in [docs/comparison.md](docs/comparison.md). The corpus is synthetic and seeded, built from real files; it is not a sample of model output.
93
144
 
145
+ For the block parsing, a second corpus of 2,613 cases over 73 real source files in all three dialects (indentation drift, trailing whitespace, CRLF, BOM, absent and ambiguous search text, near misses, marker lines inside content, escaped markers, multi-block payloads in and out of order, line hints) was run against Cline's `constructNewFileContent`, `diff-apply` 1.0.6, aider's `editblock_coder` and a hand-rolled baseline. This package applied 2,010 payloads with the expected output, refused the 603 that must be refused, and made 0 wrong applications; the alternatives made 536, 67 and 14 wrong applications respectively on the dialects they support, and silently resolved 201, 134 and 67 ambiguous payloads. Method, per-category numbers and the reproduction commands are in [docs/blocks.md](docs/blocks.md). The corpus generator ships in `test/corpus/` and runs on every build.
146
+
94
147
  ## License
95
148
 
96
149
  MIT
package/dist/index.cjs CHANGED
@@ -478,4 +478,189 @@ function applyEdits(text, edits, options = {}) {
478
478
  return {ok: true, text: splice(text, pieces), applied, lineEnding: doc.lineEnding, bom: doc.bom !== ''};
479
479
  }
480
480
 
481
- module.exports = {findEdit, applyEdit, applyEdits};
481
+ // ---------------------------------------------------------------------------
482
+ // SEARCH/REPLACE blocks: the shape models return when an agent prompts for edits as marker-delimited blocks.
483
+ // Grammars follow aider (Aider-AI/aider), Cline (cline/cline) and Roo Code (RooCodeInc/Roo-Code).
484
+
485
+ const DIALECTS = {
486
+ aider: {start: /^<{5,9} SEARCH>?\s*$/, divider: /^={5,9}\s*$/, end: /^>{5,9} REPLACE\s*$/, header: true, dividerEnds: true},
487
+ cline: {start: /^(?:-{3,}|<{3,}) SEARCH>?\s*$/, divider: /^={3,}\s*$/, end: /^(?:\+{3,}|>{3,}) REPLACE>?\s*$/},
488
+ roo: {start: /^<{7} SEARCH>?\s*$/, divider: /^={7}\s*$/, end: /^>{7} REPLACE\s*$/, hints: true, escapes: true},
489
+ };
490
+ const FENCE_LINE = /^\s*(?:```|~~~)/;
491
+ const START_HINT = /^:start_line:\s*(\d+)\s*$/;
492
+ const END_HINT = /^:end_line:\s*(\d+)\s*$/;
493
+ const HINT_SEPARATOR = /^-{7}\s*$/;
494
+ const CLINE_MARKER = /^(?:-{3,} SEARCH>?|\+{3,} REPLACE>?|<{3,4} SEARCH>?|>{3,4} REPLACE>?)\s*$/;
495
+ const ROO_ONLY = /^(?::start_line:|:end_line:|\\(?:<{7}|>{7}|={7}|-{7}))/m;
496
+ const ESCAPED_MARKER = /^\\(?=(?:<{3,}|>{3,}|={3,}|-{3,}|\+{3,}|:start_line:|:end_line:))/;
497
+
498
+ function detectDialect(text) {
499
+ if (typeof text !== 'string') throw new TypeError('text must be a string');
500
+ let aider = 0;
501
+ let cline = 0;
502
+ let roo = 0;
503
+ for (const raw of text.split('\n')) {
504
+ const line = raw.trimEnd();
505
+ if (CLINE_MARKER.test(line)) { cline++; continue; }
506
+ if (DIALECTS.roo.start.test(line)) { roo++; aider++; continue; }
507
+ if (DIALECTS.aider.start.test(line)) aider++;
508
+ }
509
+ if (cline > 0 && cline >= aider) return 'cline';
510
+ if (roo > 0 && ROO_ONLY.test(text)) return 'roo';
511
+ if (aider > 0) return 'aider';
512
+ if (cline > 0) return 'cline';
513
+ return null;
514
+ }
515
+
516
+ // Strips a repeated character from both ends without backtracking.
517
+ function stripPaired(text, character) {
518
+ let start = 0;
519
+ let end = text.length;
520
+ while (start < end && text[start] === character) start++;
521
+ while (end > start && text[end - 1] === character) end--;
522
+ return start === 0 || end === text.length || start === end ? null : text.slice(start, end).trim();
523
+ }
524
+
525
+ function cleanHeader(line) {
526
+ let name = line.trim();
527
+ if (!name) return null;
528
+ name = name.replace(/^#+\s+/, '').replace(/[:,]$/, '');
529
+ if (name.startsWith('```') || name.startsWith('~~~')) {
530
+ // A fence carrying only a language token (```ts) names no file.
531
+ const rest = name.slice(3).trim();
532
+ name = /[./\\]/.test(rest) ? rest : '';
533
+ }
534
+ if (!name) return null;
535
+ for (let guard = 0; guard < 3; guard++) {
536
+ let unwrapped = null;
537
+ if (name.length > 4 && name.startsWith('**') && name.endsWith('**')) unwrapped = name.slice(2, -2).trim();
538
+ else if (name.length > 4 && name.startsWith('__') && name.endsWith('__')) unwrapped = name.slice(2, -2).trim();
539
+ else if (name.startsWith('`') && name.endsWith('`')) unwrapped = stripPaired(name, '`');
540
+ if (!unwrapped) break;
541
+ name = unwrapped;
542
+ }
543
+ if (!/^[\w./\\@+~-]+$/.test(name)) return null;
544
+ if (!/[./\\]/.test(name) && !/^[\w-]+$/.test(name)) return null;
545
+ return name;
546
+ }
547
+
548
+ function headerFilename(lines, at, known) {
549
+ const candidates = [];
550
+ for (let k = at - 1; k >= 0 && k >= at - 3; k--) {
551
+ const name = cleanHeader(lines[k]);
552
+ if (name) candidates.push(name);
553
+ if (!FENCE_LINE.test(lines[k]) && lines[k].trim() !== '') break;
554
+ }
555
+ if (candidates.length === 0) return null;
556
+ if (known && known.length) {
557
+ for (const name of candidates) if (known.includes(name)) return name;
558
+ for (const name of candidates) {
559
+ const match = known.find(file => file.endsWith('/' + name) || file.split(/[\\/]/).pop() === name);
560
+ if (match) return match;
561
+ }
562
+ }
563
+ return candidates.find(name => /\.[A-Za-z0-9]+$/.test(name)) ?? candidates[0];
564
+ }
565
+
566
+ /** Parse SEARCH/REPLACE blocks out of a model message into edits for applyEdits. */
567
+ function parseBlocks(text, options = {}) {
568
+ if (typeof text !== 'string') throw new TypeError('text must be a string');
569
+ const dialect = options.dialect ?? detectDialect(text) ?? 'aider';
570
+ const spec = DIALECTS[dialect];
571
+ if (!spec) throw new TypeError(`unknown dialect: ${options.dialect}`);
572
+ const known = Array.isArray(options.files) ? options.files : null;
573
+ const lines = text.split('\n');
574
+ const edits = [];
575
+ const problems = [];
576
+ let file = options.file ?? null;
577
+ let i = 0;
578
+ while (i < lines.length) {
579
+ if (!spec.start.test(lines[i].trimEnd())) { i++; continue; }
580
+ const opened = i;
581
+ if (spec.header) {
582
+ const named = headerFilename(lines, i, known);
583
+ if (named) file = named;
584
+ }
585
+ i++;
586
+ let line = null;
587
+ let endLine = null;
588
+ if (spec.hints) {
589
+ for (let guard = 0; guard < 2 && i < lines.length; guard++) {
590
+ const start = lines[i].trim().match(START_HINT);
591
+ const end = lines[i].trim().match(END_HINT);
592
+ if (start) { line = Number(start[1]); i++; continue; }
593
+ if (end) { endLine = Number(end[1]); i++; continue; }
594
+ break;
595
+ }
596
+ if (i < lines.length && HINT_SEPARATOR.test(lines[i].trim())) i++;
597
+ }
598
+ const search = [];
599
+ let divided = false;
600
+ while (i < lines.length) {
601
+ const candidate = lines[i].trimEnd();
602
+ if (spec.divider.test(candidate)) { divided = true; i++; break; }
603
+ if (spec.start.test(candidate) || spec.end.test(candidate)) break;
604
+ search.push(spec.escapes ? lines[i].replace(ESCAPED_MARKER, '') : lines[i]);
605
+ i++;
606
+ }
607
+ if (!divided) {
608
+ problems.push({line: opened + 1, reason: 'missing-divider', detail: `The block opened at line ${opened + 1} has no divider line.`});
609
+ continue;
610
+ }
611
+ const replace = [];
612
+ let closed = false;
613
+ while (i < lines.length) {
614
+ const candidate = lines[i].trimEnd();
615
+ if (spec.end.test(candidate)) { closed = true; i++; break; }
616
+ if (spec.start.test(candidate)) break;
617
+ if (spec.dividerEnds && spec.divider.test(candidate)) { closed = true; i++; break; }
618
+ replace.push(spec.escapes ? lines[i].replace(ESCAPED_MARKER, '') : lines[i]);
619
+ i++;
620
+ }
621
+ if (!closed) {
622
+ problems.push({line: opened + 1, reason: 'missing-terminator', detail: `The block opened at line ${opened + 1} has no REPLACE marker.`});
623
+ continue;
624
+ }
625
+ const edit = {file, oldString: search.join('\n'), newString: replace.join('\n'), block: {line: opened + 1}};
626
+ if (line !== null) edit.line = line;
627
+ if (endLine !== null) edit.endLine = endLine;
628
+ edits.push(edit);
629
+ }
630
+ return {dialect, edits, problems};
631
+ }
632
+
633
+ /** Parse SEARCH/REPLACE blocks and apply them to one file's text. */
634
+ function applyBlocks(text, blocks, options = {}) {
635
+ if (typeof text !== 'string') throw new TypeError('text must be a string');
636
+ const parsed = parseBlocks(blocks, options);
637
+ if (parsed.problems.length) {
638
+ return {ok: false, code: 'parse', message: parsed.problems[0].detail, dialect: parsed.dialect, problems: parsed.problems};
639
+ }
640
+ if (parsed.edits.length === 0) {
641
+ return {ok: false, code: 'parse', message: 'No SEARCH/REPLACE blocks were found.', dialect: parsed.dialect, problems: []};
642
+ }
643
+ const target = options.file ?? null;
644
+ const selected = target === null ? parsed.edits : parsed.edits.filter(edit => edit.file === null || edit.file === target);
645
+ const others = new Set(parsed.edits.filter(edit => !selected.includes(edit)).map(edit => edit.file));
646
+ if (target === null) {
647
+ const named = new Set(parsed.edits.map(edit => edit.file).filter(Boolean));
648
+ if (named.size > 1) {
649
+ return {ok: false, code: 'multiple-files', message: `The blocks target ${named.size} files (${[...named].join(', ')}); pass the file option to choose one.`, dialect: parsed.dialect, files: [...named]};
650
+ }
651
+ }
652
+ if (selected.length === 0) {
653
+ return {ok: false, code: 'no-blocks-for-file', message: `No blocks target ${target}; the payload targets ${[...others].join(', ') || 'no named file'}.`, dialect: parsed.dialect};
654
+ }
655
+ // An empty search section means "replace the whole file", as Cline defines it.
656
+ const whole = selected.find(edit => edit.oldString === '');
657
+ if (whole) return {ok: true, text: whole.newString, applied: [{index: selected.indexOf(whole), strategy: 'whole-file', matches: [], reindented: false, warnings: []}], lineEnding: prepare(text).lineEnding, bom: text.startsWith(''), dialect: parsed.dialect};
658
+ const result = applyEdits(text, selected.map(edit => {
659
+ const out = {oldString: edit.oldString, newString: edit.newString};
660
+ if (edit.line !== undefined) out.line = edit.line;
661
+ return out;
662
+ }), options);
663
+ return {...result, dialect: parsed.dialect};
664
+ }
665
+
666
+ module.exports = {findEdit, applyEdit, applyEdits, detectDialect, parseBlocks, applyBlocks};
package/dist/index.d.cts CHANGED
@@ -113,3 +113,60 @@ export function applyEdit(text: string, edit: Edit, options?: Options): ApplyRes
113
113
  export function applyEdits(text: string, edits: Edit[], options?: Options): ApplyEditsResult | Failure;
114
114
  /** Locate oldString without changing anything. */
115
115
  export function findEdit(text: string, oldString: string, options?: Options): FindResult | Failure;
116
+
117
+ /** SEARCH/REPLACE marker dialect. */
118
+ export type Dialect = 'aider' | 'cline' | 'roo';
119
+
120
+ export interface ParseOptions {
121
+ /** Pin the dialect instead of detecting it. */
122
+ dialect?: Dialect;
123
+ /** File the blocks apply to; also used as the default when a block carries no filename header. */
124
+ file?: string;
125
+ /** Known file paths, used to resolve a filename header (aider dialect). */
126
+ files?: readonly string[];
127
+ }
128
+
129
+ export interface ParsedBlock {
130
+ /** Filename from the header (aider), the `file` option, or null. */
131
+ file: string | null;
132
+ oldString: string;
133
+ newString: string;
134
+ /** `:start_line:` hint (Roo dialect). */
135
+ line?: number;
136
+ /** `:end_line:` hint (Roo dialect). */
137
+ endLine?: number;
138
+ /** Where the block starts in the parsed text, 1-based. */
139
+ block: {line: number};
140
+ }
141
+
142
+ export interface BlockProblem {
143
+ /** 1-based line of the block that could not be read. */
144
+ line: number;
145
+ reason: 'missing-divider' | 'missing-terminator';
146
+ detail: string;
147
+ }
148
+
149
+ export interface ParseResult {
150
+ dialect: Dialect;
151
+ edits: ParsedBlock[];
152
+ problems: BlockProblem[];
153
+ }
154
+
155
+ export interface BlockFailure {
156
+ ok: false;
157
+ code: 'parse' | 'multiple-files' | 'no-blocks-for-file';
158
+ message: string;
159
+ dialect: Dialect;
160
+ problems?: BlockProblem[];
161
+ files?: string[];
162
+ }
163
+
164
+ /** Detect which SEARCH/REPLACE dialect a message uses, or null when it contains no blocks. */
165
+ export function detectDialect(text: string): Dialect | null;
166
+
167
+ /** Parse SEARCH/REPLACE blocks out of a model message into edits for applyEdits. */
168
+ export function parseBlocks(text: string, options?: ParseOptions): ParseResult;
169
+
170
+ /** Parse SEARCH/REPLACE blocks and apply them to one file's text. */
171
+ export function applyBlocks(text: string, blocks: string, options?: ParseOptions & Options): (ApplyEditsResult & {dialect: Dialect}) | (Failure & {dialect: Dialect}) | BlockFailure;
172
+
package/dist/index.d.mts CHANGED
@@ -113,3 +113,60 @@ export function applyEdit(text: string, edit: Edit, options?: Options): ApplyRes
113
113
  export function applyEdits(text: string, edits: Edit[], options?: Options): ApplyEditsResult | Failure;
114
114
  /** Locate oldString without changing anything. */
115
115
  export function findEdit(text: string, oldString: string, options?: Options): FindResult | Failure;
116
+
117
+ /** SEARCH/REPLACE marker dialect. */
118
+ export type Dialect = 'aider' | 'cline' | 'roo';
119
+
120
+ export interface ParseOptions {
121
+ /** Pin the dialect instead of detecting it. */
122
+ dialect?: Dialect;
123
+ /** File the blocks apply to; also used as the default when a block carries no filename header. */
124
+ file?: string;
125
+ /** Known file paths, used to resolve a filename header (aider dialect). */
126
+ files?: readonly string[];
127
+ }
128
+
129
+ export interface ParsedBlock {
130
+ /** Filename from the header (aider), the `file` option, or null. */
131
+ file: string | null;
132
+ oldString: string;
133
+ newString: string;
134
+ /** `:start_line:` hint (Roo dialect). */
135
+ line?: number;
136
+ /** `:end_line:` hint (Roo dialect). */
137
+ endLine?: number;
138
+ /** Where the block starts in the parsed text, 1-based. */
139
+ block: {line: number};
140
+ }
141
+
142
+ export interface BlockProblem {
143
+ /** 1-based line of the block that could not be read. */
144
+ line: number;
145
+ reason: 'missing-divider' | 'missing-terminator';
146
+ detail: string;
147
+ }
148
+
149
+ export interface ParseResult {
150
+ dialect: Dialect;
151
+ edits: ParsedBlock[];
152
+ problems: BlockProblem[];
153
+ }
154
+
155
+ export interface BlockFailure {
156
+ ok: false;
157
+ code: 'parse' | 'multiple-files' | 'no-blocks-for-file';
158
+ message: string;
159
+ dialect: Dialect;
160
+ problems?: BlockProblem[];
161
+ files?: string[];
162
+ }
163
+
164
+ /** Detect which SEARCH/REPLACE dialect a message uses, or null when it contains no blocks. */
165
+ export function detectDialect(text: string): Dialect | null;
166
+
167
+ /** Parse SEARCH/REPLACE blocks out of a model message into edits for applyEdits. */
168
+ export function parseBlocks(text: string, options?: ParseOptions): ParseResult;
169
+
170
+ /** Parse SEARCH/REPLACE blocks and apply them to one file's text. */
171
+ export function applyBlocks(text: string, blocks: string, options?: ParseOptions & Options): (ApplyEditsResult & {dialect: Dialect}) | (Failure & {dialect: Dialect}) | BlockFailure;
172
+
package/dist/index.mjs CHANGED
@@ -476,3 +476,188 @@ export function applyEdits(text, edits, options = {}) {
476
476
  }
477
477
  return {ok: true, text: splice(text, pieces), applied, lineEnding: doc.lineEnding, bom: doc.bom !== ''};
478
478
  }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // SEARCH/REPLACE blocks: the shape models return when an agent prompts for edits as marker-delimited blocks.
482
+ // Grammars follow aider (Aider-AI/aider), Cline (cline/cline) and Roo Code (RooCodeInc/Roo-Code).
483
+
484
+ const DIALECTS = {
485
+ aider: {start: /^<{5,9} SEARCH>?\s*$/, divider: /^={5,9}\s*$/, end: /^>{5,9} REPLACE\s*$/, header: true, dividerEnds: true},
486
+ cline: {start: /^(?:-{3,}|<{3,}) SEARCH>?\s*$/, divider: /^={3,}\s*$/, end: /^(?:\+{3,}|>{3,}) REPLACE>?\s*$/},
487
+ roo: {start: /^<{7} SEARCH>?\s*$/, divider: /^={7}\s*$/, end: /^>{7} REPLACE\s*$/, hints: true, escapes: true},
488
+ };
489
+ const FENCE_LINE = /^\s*(?:```|~~~)/;
490
+ const START_HINT = /^:start_line:\s*(\d+)\s*$/;
491
+ const END_HINT = /^:end_line:\s*(\d+)\s*$/;
492
+ const HINT_SEPARATOR = /^-{7}\s*$/;
493
+ const CLINE_MARKER = /^(?:-{3,} SEARCH>?|\+{3,} REPLACE>?|<{3,4} SEARCH>?|>{3,4} REPLACE>?)\s*$/;
494
+ const ROO_ONLY = /^(?::start_line:|:end_line:|\\(?:<{7}|>{7}|={7}|-{7}))/m;
495
+ const ESCAPED_MARKER = /^\\(?=(?:<{3,}|>{3,}|={3,}|-{3,}|\+{3,}|:start_line:|:end_line:))/;
496
+
497
+ export function detectDialect(text) {
498
+ if (typeof text !== 'string') throw new TypeError('text must be a string');
499
+ let aider = 0;
500
+ let cline = 0;
501
+ let roo = 0;
502
+ for (const raw of text.split('\n')) {
503
+ const line = raw.trimEnd();
504
+ if (CLINE_MARKER.test(line)) { cline++; continue; }
505
+ if (DIALECTS.roo.start.test(line)) { roo++; aider++; continue; }
506
+ if (DIALECTS.aider.start.test(line)) aider++;
507
+ }
508
+ if (cline > 0 && cline >= aider) return 'cline';
509
+ if (roo > 0 && ROO_ONLY.test(text)) return 'roo';
510
+ if (aider > 0) return 'aider';
511
+ if (cline > 0) return 'cline';
512
+ return null;
513
+ }
514
+
515
+ // Strips a repeated character from both ends without backtracking.
516
+ function stripPaired(text, character) {
517
+ let start = 0;
518
+ let end = text.length;
519
+ while (start < end && text[start] === character) start++;
520
+ while (end > start && text[end - 1] === character) end--;
521
+ return start === 0 || end === text.length || start === end ? null : text.slice(start, end).trim();
522
+ }
523
+
524
+ function cleanHeader(line) {
525
+ let name = line.trim();
526
+ if (!name) return null;
527
+ name = name.replace(/^#+\s+/, '').replace(/[:,]$/, '');
528
+ if (name.startsWith('```') || name.startsWith('~~~')) {
529
+ // A fence carrying only a language token (```ts) names no file.
530
+ const rest = name.slice(3).trim();
531
+ name = /[./\\]/.test(rest) ? rest : '';
532
+ }
533
+ if (!name) return null;
534
+ for (let guard = 0; guard < 3; guard++) {
535
+ let unwrapped = null;
536
+ if (name.length > 4 && name.startsWith('**') && name.endsWith('**')) unwrapped = name.slice(2, -2).trim();
537
+ else if (name.length > 4 && name.startsWith('__') && name.endsWith('__')) unwrapped = name.slice(2, -2).trim();
538
+ else if (name.startsWith('`') && name.endsWith('`')) unwrapped = stripPaired(name, '`');
539
+ if (!unwrapped) break;
540
+ name = unwrapped;
541
+ }
542
+ if (!/^[\w./\\@+~-]+$/.test(name)) return null;
543
+ if (!/[./\\]/.test(name) && !/^[\w-]+$/.test(name)) return null;
544
+ return name;
545
+ }
546
+
547
+ function headerFilename(lines, at, known) {
548
+ const candidates = [];
549
+ for (let k = at - 1; k >= 0 && k >= at - 3; k--) {
550
+ const name = cleanHeader(lines[k]);
551
+ if (name) candidates.push(name);
552
+ if (!FENCE_LINE.test(lines[k]) && lines[k].trim() !== '') break;
553
+ }
554
+ if (candidates.length === 0) return null;
555
+ if (known && known.length) {
556
+ for (const name of candidates) if (known.includes(name)) return name;
557
+ for (const name of candidates) {
558
+ const match = known.find(file => file.endsWith('/' + name) || file.split(/[\\/]/).pop() === name);
559
+ if (match) return match;
560
+ }
561
+ }
562
+ return candidates.find(name => /\.[A-Za-z0-9]+$/.test(name)) ?? candidates[0];
563
+ }
564
+
565
+ /** Parse SEARCH/REPLACE blocks out of a model message into edits for applyEdits. */
566
+ export function parseBlocks(text, options = {}) {
567
+ if (typeof text !== 'string') throw new TypeError('text must be a string');
568
+ const dialect = options.dialect ?? detectDialect(text) ?? 'aider';
569
+ const spec = DIALECTS[dialect];
570
+ if (!spec) throw new TypeError(`unknown dialect: ${options.dialect}`);
571
+ const known = Array.isArray(options.files) ? options.files : null;
572
+ const lines = text.split('\n');
573
+ const edits = [];
574
+ const problems = [];
575
+ let file = options.file ?? null;
576
+ let i = 0;
577
+ while (i < lines.length) {
578
+ if (!spec.start.test(lines[i].trimEnd())) { i++; continue; }
579
+ const opened = i;
580
+ if (spec.header) {
581
+ const named = headerFilename(lines, i, known);
582
+ if (named) file = named;
583
+ }
584
+ i++;
585
+ let line = null;
586
+ let endLine = null;
587
+ if (spec.hints) {
588
+ for (let guard = 0; guard < 2 && i < lines.length; guard++) {
589
+ const start = lines[i].trim().match(START_HINT);
590
+ const end = lines[i].trim().match(END_HINT);
591
+ if (start) { line = Number(start[1]); i++; continue; }
592
+ if (end) { endLine = Number(end[1]); i++; continue; }
593
+ break;
594
+ }
595
+ if (i < lines.length && HINT_SEPARATOR.test(lines[i].trim())) i++;
596
+ }
597
+ const search = [];
598
+ let divided = false;
599
+ while (i < lines.length) {
600
+ const candidate = lines[i].trimEnd();
601
+ if (spec.divider.test(candidate)) { divided = true; i++; break; }
602
+ if (spec.start.test(candidate) || spec.end.test(candidate)) break;
603
+ search.push(spec.escapes ? lines[i].replace(ESCAPED_MARKER, '') : lines[i]);
604
+ i++;
605
+ }
606
+ if (!divided) {
607
+ problems.push({line: opened + 1, reason: 'missing-divider', detail: `The block opened at line ${opened + 1} has no divider line.`});
608
+ continue;
609
+ }
610
+ const replace = [];
611
+ let closed = false;
612
+ while (i < lines.length) {
613
+ const candidate = lines[i].trimEnd();
614
+ if (spec.end.test(candidate)) { closed = true; i++; break; }
615
+ if (spec.start.test(candidate)) break;
616
+ if (spec.dividerEnds && spec.divider.test(candidate)) { closed = true; i++; break; }
617
+ replace.push(spec.escapes ? lines[i].replace(ESCAPED_MARKER, '') : lines[i]);
618
+ i++;
619
+ }
620
+ if (!closed) {
621
+ problems.push({line: opened + 1, reason: 'missing-terminator', detail: `The block opened at line ${opened + 1} has no REPLACE marker.`});
622
+ continue;
623
+ }
624
+ const edit = {file, oldString: search.join('\n'), newString: replace.join('\n'), block: {line: opened + 1}};
625
+ if (line !== null) edit.line = line;
626
+ if (endLine !== null) edit.endLine = endLine;
627
+ edits.push(edit);
628
+ }
629
+ return {dialect, edits, problems};
630
+ }
631
+
632
+ /** Parse SEARCH/REPLACE blocks and apply them to one file's text. */
633
+ export function applyBlocks(text, blocks, options = {}) {
634
+ if (typeof text !== 'string') throw new TypeError('text must be a string');
635
+ const parsed = parseBlocks(blocks, options);
636
+ if (parsed.problems.length) {
637
+ return {ok: false, code: 'parse', message: parsed.problems[0].detail, dialect: parsed.dialect, problems: parsed.problems};
638
+ }
639
+ if (parsed.edits.length === 0) {
640
+ return {ok: false, code: 'parse', message: 'No SEARCH/REPLACE blocks were found.', dialect: parsed.dialect, problems: []};
641
+ }
642
+ const target = options.file ?? null;
643
+ const selected = target === null ? parsed.edits : parsed.edits.filter(edit => edit.file === null || edit.file === target);
644
+ const others = new Set(parsed.edits.filter(edit => !selected.includes(edit)).map(edit => edit.file));
645
+ if (target === null) {
646
+ const named = new Set(parsed.edits.map(edit => edit.file).filter(Boolean));
647
+ if (named.size > 1) {
648
+ return {ok: false, code: 'multiple-files', message: `The blocks target ${named.size} files (${[...named].join(', ')}); pass the file option to choose one.`, dialect: parsed.dialect, files: [...named]};
649
+ }
650
+ }
651
+ if (selected.length === 0) {
652
+ return {ok: false, code: 'no-blocks-for-file', message: `No blocks target ${target}; the payload targets ${[...others].join(', ') || 'no named file'}.`, dialect: parsed.dialect};
653
+ }
654
+ // An empty search section means "replace the whole file", as Cline defines it.
655
+ const whole = selected.find(edit => edit.oldString === '');
656
+ if (whole) return {ok: true, text: whole.newString, applied: [{index: selected.indexOf(whole), strategy: 'whole-file', matches: [], reindented: false, warnings: []}], lineEnding: prepare(text).lineEnding, bom: text.startsWith(''), dialect: parsed.dialect};
657
+ const result = applyEdits(text, selected.map(edit => {
658
+ const out = {oldString: edit.oldString, newString: edit.newString};
659
+ if (edit.line !== undefined) out.line = edit.line;
660
+ return out;
661
+ }), options);
662
+ return {...result, dialect: parsed.dialect};
663
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apply-edit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Apply LLM-generated oldString/newString edits safely: tolerant matching with ambiguity refusal, match reports, re-indentation and CRLF/BOM preservation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -43,7 +43,7 @@
43
43
  ],
44
44
  "scripts": {
45
45
  "build": "node scripts/build.mjs",
46
- "test": "node --test test/api.test.mjs test/issues.test.mjs",
46
+ "test": "node --test test/api.test.mjs test/issues.test.mjs test/blocks.test.mjs test/corpus.test.mjs",
47
47
  "test:types": "tsc -p test/tsconfig.json",
48
48
  "test:pack": "node scripts/test-pack.mjs",
49
49
  "verify": "npm run build && npm test && npm run test:types && npm run test:pack",