@oh-my-pi/pi-coding-agent 13.18.0 → 14.0.2
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 +316 -1
- package/package.json +86 -24
- package/scripts/format-prompts.ts +2 -2
- package/src/autoresearch/apply-contract-to-state.ts +24 -0
- package/src/autoresearch/contract.ts +0 -44
- package/src/autoresearch/dashboard.ts +1 -2
- package/src/autoresearch/git.ts +116 -30
- package/src/autoresearch/helpers.ts +49 -0
- package/src/autoresearch/index.ts +28 -187
- package/src/autoresearch/prompt.md +26 -9
- package/src/autoresearch/state.ts +0 -6
- package/src/autoresearch/tools/init-experiment.ts +202 -117
- package/src/autoresearch/tools/log-experiment.ts +123 -178
- package/src/autoresearch/tools/run-experiment.ts +48 -10
- package/src/autoresearch/types.ts +2 -2
- package/src/capability/index.ts +4 -2
- package/src/cli/file-processor.ts +3 -3
- package/src/cli/grep-cli.ts +8 -8
- package/src/cli/grievances-cli.ts +78 -0
- package/src/cli/read-cli.ts +67 -0
- package/src/cli/setup-cli.ts +4 -4
- package/src/cli/update-cli.ts +3 -3
- package/src/cli.ts +2 -0
- package/src/commands/grep.ts +6 -1
- package/src/commands/grievances.ts +20 -0
- package/src/commands/read.ts +33 -0
- package/src/commit/agentic/agent.ts +5 -8
- package/src/commit/agentic/index.ts +22 -26
- package/src/commit/agentic/tools/analyze-file.ts +3 -3
- package/src/commit/agentic/tools/git-file-diff.ts +3 -6
- package/src/commit/agentic/tools/git-hunk.ts +3 -3
- package/src/commit/agentic/tools/git-overview.ts +6 -9
- package/src/commit/agentic/tools/index.ts +6 -8
- package/src/commit/agentic/tools/propose-commit.ts +4 -7
- package/src/commit/agentic/tools/recent-commits.ts +3 -3
- package/src/commit/agentic/tools/split-commit.ts +4 -4
- package/src/commit/agentic/validation.ts +1 -1
- package/src/commit/analysis/conventional.ts +4 -4
- package/src/commit/analysis/summary.ts +3 -3
- package/src/commit/changelog/generate.ts +4 -4
- package/src/commit/changelog/index.ts +5 -9
- package/src/commit/map-reduce/map-phase.ts +4 -4
- package/src/commit/map-reduce/reduce-phase.ts +4 -4
- package/src/commit/pipeline.ts +13 -16
- package/src/config/keybindings.ts +7 -6
- package/src/config/prompt-templates.ts +44 -226
- package/src/config/resolve-config-value.ts +4 -2
- package/src/config/settings-schema.ts +98 -2
- package/src/config/settings.ts +25 -26
- package/src/dap/client.ts +674 -0
- package/src/dap/config.ts +150 -0
- package/src/dap/defaults.json +211 -0
- package/src/dap/index.ts +4 -0
- package/src/dap/session.ts +1255 -0
- package/src/dap/types.ts +600 -0
- package/src/debug/log-viewer.ts +3 -2
- package/src/discovery/builtin.ts +1 -2
- package/src/discovery/codex.ts +2 -2
- package/src/discovery/github.ts +2 -1
- package/src/discovery/helpers.ts +2 -2
- package/src/discovery/opencode.ts +2 -2
- package/src/edit/diff.ts +818 -0
- package/src/edit/index.ts +309 -0
- package/src/edit/line-hash.ts +67 -0
- package/src/edit/modes/chunk.ts +454 -0
- package/src/{patch → edit/modes}/hashline.ts +741 -361
- package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
- package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
- package/src/{patch → edit}/normalize.ts +97 -76
- package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
- package/src/exec/bash-executor.ts +4 -2
- package/src/exec/idle-timeout-watchdog.ts +126 -0
- package/src/exec/non-interactive-env.ts +5 -0
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +6 -18
- package/src/extensibility/custom-commands/bundled/review/index.ts +45 -43
- package/src/extensibility/custom-commands/loader.ts +1 -2
- package/src/extensibility/custom-tools/loader.ts +34 -11
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/loader.ts +9 -4
- package/src/extensibility/extensions/runner.ts +24 -1
- package/src/extensibility/extensions/types.ts +4 -2
- package/src/extensibility/hooks/loader.ts +5 -6
- package/src/extensibility/hooks/types.ts +2 -2
- package/src/extensibility/plugins/doctor.ts +2 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
- package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
- package/src/extensibility/slash-commands.ts +3 -7
- package/src/index.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +11 -11
- package/src/ipy/executor.ts +58 -17
- package/src/ipy/gateway-coordinator.ts +6 -4
- package/src/ipy/kernel.ts +45 -22
- package/src/ipy/runtime.ts +2 -2
- package/src/lsp/client.ts +7 -4
- package/src/lsp/clients/lsp-linter-client.ts +4 -4
- package/src/lsp/config.ts +2 -2
- package/src/lsp/defaults.json +688 -154
- package/src/lsp/index.ts +234 -45
- package/src/lsp/lspmux.ts +2 -2
- package/src/lsp/startup-events.ts +13 -0
- package/src/lsp/types.ts +12 -1
- package/src/lsp/utils.ts +8 -1
- package/src/main.ts +125 -47
- package/src/memories/index.ts +4 -5
- package/src/modes/acp/acp-agent.ts +563 -163
- package/src/modes/acp/acp-event-mapper.ts +9 -1
- package/src/modes/acp/acp-mode.ts +4 -2
- package/src/modes/components/agent-dashboard.ts +3 -4
- package/src/modes/components/diff.ts +6 -7
- package/src/modes/components/footer.ts +9 -29
- package/src/modes/components/hook-editor.ts +3 -3
- package/src/modes/components/hook-selector.ts +6 -1
- package/src/modes/components/read-tool-group.ts +6 -12
- package/src/modes/components/session-observer-overlay.ts +472 -0
- package/src/modes/components/settings-defs.ts +24 -0
- package/src/modes/components/status-line.ts +15 -61
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/btw-controller.ts +2 -2
- package/src/modes/controllers/command-controller.ts +4 -2
- package/src/modes/controllers/event-controller.ts +59 -2
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +15 -8
- package/src/modes/controllers/selector-controller.ts +26 -0
- package/src/modes/index.ts +20 -2
- package/src/modes/interactive-mode.ts +278 -69
- package/src/modes/rpc/host-tools.ts +186 -0
- package/src/modes/rpc/rpc-client.ts +178 -13
- package/src/modes/rpc/rpc-mode.ts +73 -3
- package/src/modes/rpc/rpc-types.ts +53 -1
- package/src/modes/session-observer-registry.ts +146 -0
- package/src/modes/shared.ts +0 -42
- package/src/modes/theme/theme.ts +80 -8
- package/src/modes/types.ts +4 -2
- package/src/modes/utils/keybinding-matchers.ts +9 -0
- package/src/prompts/system/custom-system-prompt.md +5 -0
- package/src/prompts/system/system-prompt.md +8 -1
- package/src/prompts/tools/chunk-edit.md +219 -0
- package/src/prompts/tools/debug.md +43 -0
- package/src/prompts/tools/grep.md +3 -0
- package/src/prompts/tools/lsp.md +5 -5
- package/src/prompts/tools/read-chunk.md +17 -0
- package/src/prompts/tools/read.md +19 -5
- package/src/sdk.ts +216 -165
- package/src/secrets/index.ts +1 -1
- package/src/secrets/obfuscator.ts +25 -17
- package/src/session/agent-session.ts +381 -286
- package/src/session/agent-storage.ts +12 -12
- package/src/session/compaction/branch-summarization.ts +3 -3
- package/src/session/compaction/compaction.ts +5 -6
- package/src/session/compaction/utils.ts +3 -3
- package/src/session/history-storage.ts +62 -19
- package/src/session/messages.ts +3 -3
- package/src/session/session-dump-format.ts +203 -0
- package/src/session/session-manager.ts +15 -5
- package/src/session/session-storage.ts +4 -2
- package/src/session/streaming-output.ts +1 -1
- package/src/session/tool-choice-queue.ts +213 -0
- package/src/slash-commands/builtin-registry.ts +56 -8
- package/src/ssh/connection-manager.ts +2 -2
- package/src/ssh/sshfs-mount.ts +5 -5
- package/src/stt/downloader.ts +4 -4
- package/src/stt/recorder.ts +4 -4
- package/src/stt/transcriber.ts +2 -2
- package/src/system-prompt.ts +25 -13
- package/src/task/agents.ts +5 -6
- package/src/task/commands.ts +2 -5
- package/src/task/executor.ts +32 -4
- package/src/task/index.ts +91 -82
- package/src/task/template.ts +2 -2
- package/src/task/types.ts +25 -0
- package/src/task/worktree.ts +131 -149
- package/src/tools/ask.ts +2 -3
- package/src/tools/ast-edit.ts +7 -7
- package/src/tools/ast-grep.ts +7 -7
- package/src/tools/auto-generated-guard.ts +36 -41
- package/src/tools/await-tool.ts +2 -2
- package/src/tools/bash.ts +5 -23
- package/src/tools/browser.ts +4 -5
- package/src/tools/calculator.ts +2 -3
- package/src/tools/cancel-job.ts +2 -2
- package/src/tools/checkpoint.ts +3 -3
- package/src/tools/debug.ts +1007 -0
- package/src/tools/exit-plan-mode.ts +3 -3
- package/src/tools/fetch.ts +67 -3
- package/src/tools/find.ts +4 -5
- package/src/tools/fs-cache-invalidation.ts +5 -0
- package/src/tools/gemini-image.ts +13 -5
- package/src/tools/gh.ts +130 -308
- package/src/tools/grep.ts +57 -9
- package/src/tools/index.ts +44 -22
- package/src/tools/inspect-image.ts +4 -4
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/python.ts +19 -6
- package/src/tools/read.ts +211 -146
- package/src/tools/render-mermaid.ts +2 -3
- package/src/tools/render-utils.ts +20 -6
- package/src/tools/renderers.ts +3 -1
- package/src/tools/report-tool-issue.ts +80 -0
- package/src/tools/resolve.ts +70 -39
- package/src/tools/search-tool-bm25.ts +2 -2
- package/src/tools/ssh.ts +2 -2
- package/src/tools/todo-write.ts +2 -2
- package/src/tools/tool-timeouts.ts +1 -0
- package/src/tools/write.ts +5 -6
- package/src/tui/tree-list.ts +3 -1
- package/src/utils/clipboard.ts +80 -0
- package/src/utils/commit-message-generator.ts +2 -3
- package/src/utils/edit-mode.ts +49 -0
- package/src/utils/external-editor.ts +11 -5
- package/src/utils/file-display-mode.ts +6 -5
- package/src/utils/file-mentions.ts +8 -7
- package/src/utils/git.ts +1400 -0
- package/src/utils/image-loading.ts +98 -0
- package/src/utils/title-generator.ts +2 -3
- package/src/utils/tools-manager.ts +6 -6
- package/src/web/scrapers/choosealicense.ts +1 -1
- package/src/web/search/index.ts +3 -3
- package/src/web/search/render.ts +6 -4
- package/src/autoresearch/command-initialize.md +0 -34
- package/src/commit/git/errors.ts +0 -9
- package/src/commit/git/index.ts +0 -210
- package/src/commit/git/operations.ts +0 -54
- package/src/patch/diff.ts +0 -433
- package/src/patch/index.ts +0 -888
- package/src/patch/parser.ts +0 -532
- package/src/patch/types.ts +0 -292
- package/src/prompts/agents/oracle.md +0 -77
- package/src/tools/gh-cli.ts +0 -125
- package/src/tools/pending-action.ts +0 -49
- package/src/utils/child-process.ts +0 -88
- package/src/utils/frontmatter.ts +0 -117
- package/src/utils/image-input.ts +0 -274
- package/src/utils/mime.ts +0 -53
- package/src/utils/prompt-format.ts +0 -170
package/src/edit/diff.ts
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff generation and replace-mode utilities for the edit tool.
|
|
3
|
+
*
|
|
4
|
+
* Provides diff string generation and the replace-mode edit logic
|
|
5
|
+
* used when not in patch mode.
|
|
6
|
+
*/
|
|
7
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
8
|
+
import * as Diff from "diff";
|
|
9
|
+
import { resolveToCwd } from "../tools/path-utils";
|
|
10
|
+
import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace";
|
|
11
|
+
import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
|
|
12
|
+
|
|
13
|
+
export interface DiffResult {
|
|
14
|
+
diff: string;
|
|
15
|
+
firstChangedLine: number | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DiffError {
|
|
19
|
+
error: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DiffHunk {
|
|
23
|
+
changeContext?: string;
|
|
24
|
+
oldStartLine?: number;
|
|
25
|
+
newStartLine?: number;
|
|
26
|
+
hasContextLines: boolean;
|
|
27
|
+
oldLines: string[];
|
|
28
|
+
newLines: string[];
|
|
29
|
+
isEndOfFile: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class ParseError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
message: string,
|
|
35
|
+
readonly lineNumber?: number,
|
|
36
|
+
) {
|
|
37
|
+
super(lineNumber !== undefined ? `Line ${lineNumber}: ${message}` : message);
|
|
38
|
+
this.name = "ParseError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class ApplyPatchError extends Error {
|
|
43
|
+
constructor(message: string) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "ApplyPatchError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
50
|
+
// Diff String Generation
|
|
51
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
52
|
+
|
|
53
|
+
function countContentLines(content: string): number {
|
|
54
|
+
const lines = content.split("\n");
|
|
55
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") {
|
|
56
|
+
lines.pop();
|
|
57
|
+
}
|
|
58
|
+
return Math.max(1, lines.length);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, width: number, content: string): string {
|
|
62
|
+
const padded = String(lineNum).padStart(width, " ");
|
|
63
|
+
return `${prefix}${padded}|${content}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Generate a unified diff string with line numbers and context.
|
|
68
|
+
* Returns both the diff string and the first changed line number (in the new file).
|
|
69
|
+
*/
|
|
70
|
+
export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult {
|
|
71
|
+
const parts = Diff.diffLines(oldContent, newContent);
|
|
72
|
+
const output: string[] = [];
|
|
73
|
+
|
|
74
|
+
const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent));
|
|
75
|
+
const lineNumWidth = String(maxLineNum).length;
|
|
76
|
+
|
|
77
|
+
let oldLineNum = 1;
|
|
78
|
+
let newLineNum = 1;
|
|
79
|
+
let lastWasChange = false;
|
|
80
|
+
let firstChangedLine: number | undefined;
|
|
81
|
+
|
|
82
|
+
for (let i = 0; i < parts.length; i++) {
|
|
83
|
+
const part = parts[i];
|
|
84
|
+
const raw = part.value.split("\n");
|
|
85
|
+
if (raw[raw.length - 1] === "") {
|
|
86
|
+
raw.pop();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (part.added || part.removed) {
|
|
90
|
+
// Capture the first changed line (in the new file)
|
|
91
|
+
if (firstChangedLine === undefined) {
|
|
92
|
+
firstChangedLine = newLineNum;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Show the change
|
|
96
|
+
for (const line of raw) {
|
|
97
|
+
if (part.added) {
|
|
98
|
+
output.push(formatNumberedDiffLine("+", newLineNum, lineNumWidth, line));
|
|
99
|
+
newLineNum++;
|
|
100
|
+
} else {
|
|
101
|
+
output.push(formatNumberedDiffLine("-", oldLineNum, lineNumWidth, line));
|
|
102
|
+
oldLineNum++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
lastWasChange = true;
|
|
106
|
+
} else {
|
|
107
|
+
// Context lines - only show a few before/after changes
|
|
108
|
+
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
|
109
|
+
|
|
110
|
+
if (lastWasChange || nextPartIsChange) {
|
|
111
|
+
let linesToShow = raw;
|
|
112
|
+
let skipStart = 0;
|
|
113
|
+
let skipEnd = 0;
|
|
114
|
+
|
|
115
|
+
if (!lastWasChange) {
|
|
116
|
+
// Show only last N lines as leading context
|
|
117
|
+
skipStart = Math.max(0, raw.length - contextLines);
|
|
118
|
+
linesToShow = raw.slice(skipStart);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!nextPartIsChange && linesToShow.length > contextLines) {
|
|
122
|
+
// Show only first N lines as trailing context
|
|
123
|
+
skipEnd = linesToShow.length - contextLines;
|
|
124
|
+
linesToShow = linesToShow.slice(0, contextLines);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Add ellipsis if we skipped lines at start
|
|
128
|
+
if (skipStart > 0) {
|
|
129
|
+
output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
|
|
130
|
+
oldLineNum += skipStart;
|
|
131
|
+
newLineNum += skipStart;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const line of linesToShow) {
|
|
135
|
+
output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, line));
|
|
136
|
+
oldLineNum++;
|
|
137
|
+
newLineNum++;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Add ellipsis if we skipped lines at end
|
|
141
|
+
if (skipEnd > 0) {
|
|
142
|
+
output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
|
|
143
|
+
oldLineNum += skipEnd;
|
|
144
|
+
newLineNum += skipEnd;
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
// Skip these context lines entirely
|
|
148
|
+
oldLineNum += raw.length;
|
|
149
|
+
newLineNum += raw.length;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
lastWasChange = false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { diff: output.join("\n"), firstChangedLine };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
160
|
+
// Replace Mode Logic
|
|
161
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
162
|
+
|
|
163
|
+
export interface ReplaceOptions {
|
|
164
|
+
/** Allow fuzzy matching */
|
|
165
|
+
fuzzy: boolean;
|
|
166
|
+
/** Replace all occurrences */
|
|
167
|
+
all: boolean;
|
|
168
|
+
/** Similarity threshold for fuzzy matching */
|
|
169
|
+
threshold?: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface ReplaceResult {
|
|
173
|
+
/** The new content after replacements */
|
|
174
|
+
content: string;
|
|
175
|
+
/** Number of replacements made */
|
|
176
|
+
count: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Generate a unified diff string without file headers.
|
|
181
|
+
* Returns both the diff string and the first changed line number (in the new file).
|
|
182
|
+
*/
|
|
183
|
+
export function generateUnifiedDiffString(oldContent: string, newContent: string, contextLines = 3): DiffResult {
|
|
184
|
+
const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines });
|
|
185
|
+
const output: string[] = [];
|
|
186
|
+
let firstChangedLine: number | undefined;
|
|
187
|
+
const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent));
|
|
188
|
+
const lineNumWidth = String(maxLineNum).length;
|
|
189
|
+
for (const hunk of patch.hunks) {
|
|
190
|
+
output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
|
|
191
|
+
let oldLine = hunk.oldStart;
|
|
192
|
+
let newLine = hunk.newStart;
|
|
193
|
+
for (const line of hunk.lines) {
|
|
194
|
+
if (line.startsWith("-")) {
|
|
195
|
+
if (firstChangedLine === undefined) firstChangedLine = newLine;
|
|
196
|
+
output.push(formatNumberedDiffLine("-", oldLine, lineNumWidth, line.slice(1)));
|
|
197
|
+
oldLine++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (line.startsWith("+")) {
|
|
201
|
+
if (firstChangedLine === undefined) firstChangedLine = newLine;
|
|
202
|
+
output.push(formatNumberedDiffLine("+", newLine, lineNumWidth, line.slice(1)));
|
|
203
|
+
newLine++;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (line.startsWith(" ")) {
|
|
207
|
+
output.push(formatNumberedDiffLine(" ", oldLine, lineNumWidth, line.slice(1)));
|
|
208
|
+
oldLine++;
|
|
209
|
+
newLine++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
output.push(line);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { diff: output.join("\n"), firstChangedLine };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const EOF_MARKER = "*** End of File";
|
|
220
|
+
const CHANGE_CONTEXT_MARKER = "@@ ";
|
|
221
|
+
const EMPTY_CHANGE_CONTEXT_MARKER = "@@";
|
|
222
|
+
const UNIFIED_HUNK_HEADER_REGEX = /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/;
|
|
223
|
+
const LINE_HINT_REGEX = /^lines?\s+(\d+)(?:\s*-\s*(\d+))?(?:\s*@@)?$/i;
|
|
224
|
+
const TOP_OF_FILE_REGEX = /^(top|start|beginning)\s+of\s+file$/i;
|
|
225
|
+
const MULTI_FILE_MARKERS = ["*** Update File:", "*** Add File:", "*** Delete File:", "diff --git "];
|
|
226
|
+
const DIFF_METADATA_PREFIXES = [
|
|
227
|
+
"*** Update File:",
|
|
228
|
+
"*** Add File:",
|
|
229
|
+
"*** Delete File:",
|
|
230
|
+
"diff --git ",
|
|
231
|
+
"index ",
|
|
232
|
+
"--- ",
|
|
233
|
+
"+++ ",
|
|
234
|
+
"new file mode ",
|
|
235
|
+
"deleted file mode ",
|
|
236
|
+
"rename from ",
|
|
237
|
+
"rename to ",
|
|
238
|
+
"similarity index ",
|
|
239
|
+
"dissimilarity index ",
|
|
240
|
+
"old mode ",
|
|
241
|
+
"new mode ",
|
|
242
|
+
];
|
|
243
|
+
const PATCH_WRAPPER_PREFIXES = ["*** Begin Patch", "*** End Patch"];
|
|
244
|
+
const MAX_OCCURRENCE_PREVIEWS = 5;
|
|
245
|
+
|
|
246
|
+
function isDiffContentLine(line: string): boolean {
|
|
247
|
+
const firstChar = line[0];
|
|
248
|
+
if (firstChar === " ") return true;
|
|
249
|
+
if (firstChar === "+") {
|
|
250
|
+
return !line.startsWith("+++ ");
|
|
251
|
+
}
|
|
252
|
+
if (firstChar === "-") {
|
|
253
|
+
return !line.startsWith("--- ");
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function matchesTrimmedPrefix(line: string, prefixes: string[]): boolean {
|
|
259
|
+
return prefixes.some(prefix => line.startsWith(prefix));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isPatchWrapperLine(line: string): boolean {
|
|
263
|
+
return line === "***" || matchesTrimmedPrefix(line, PATCH_WRAPPER_PREFIXES);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function formatOccurrenceMatchError(
|
|
267
|
+
occurrences: number,
|
|
268
|
+
occurrencePreviews: string[] | undefined,
|
|
269
|
+
path?: string,
|
|
270
|
+
): string {
|
|
271
|
+
const previews = occurrencePreviews?.join("\n\n") ?? "";
|
|
272
|
+
const moreMsg =
|
|
273
|
+
occurrences > MAX_OCCURRENCE_PREVIEWS ? ` (showing first ${MAX_OCCURRENCE_PREVIEWS} of ${occurrences})` : "";
|
|
274
|
+
const pathSuffix = path ? ` in ${path}` : "";
|
|
275
|
+
return `Found ${occurrences} occurrences${pathSuffix}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function readFileTextForDiff(path: string, absolutePath: string): Promise<string> {
|
|
279
|
+
try {
|
|
280
|
+
return await Bun.file(absolutePath).text();
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (isEnoent(error)) {
|
|
283
|
+
throw new Error(`File not found: ${path}`);
|
|
284
|
+
}
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function normalizeDiff(diff: string): string {
|
|
290
|
+
let lines = diff.split("\n");
|
|
291
|
+
|
|
292
|
+
while (lines.length > 0) {
|
|
293
|
+
const lastLine = lines[lines.length - 1];
|
|
294
|
+
if (lastLine === "" || (lastLine?.trim() === "" && !isDiffContentLine(lastLine ?? ""))) {
|
|
295
|
+
lines = lines.slice(0, -1);
|
|
296
|
+
} else {
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (lines[0] && isPatchWrapperLine(lines[0].trim())) {
|
|
302
|
+
lines = lines.slice(1);
|
|
303
|
+
}
|
|
304
|
+
if (lines.length > 0 && isPatchWrapperLine(lines[lines.length - 1]?.trim() ?? "")) {
|
|
305
|
+
lines = lines.slice(0, -1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
lines = lines.filter(line => {
|
|
309
|
+
if (isDiffContentLine(line)) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return !matchesTrimmedPrefix(line.trim(), DIFF_METADATA_PREFIXES);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return lines.join("\n");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function normalizeCreateContent(content: string): string {
|
|
320
|
+
const lines = content.split("\n");
|
|
321
|
+
const nonEmptyLines = lines.filter(line => line.length > 0);
|
|
322
|
+
|
|
323
|
+
if (nonEmptyLines.length > 0 && nonEmptyLines.every(line => line.startsWith("+ ") || line.startsWith("+"))) {
|
|
324
|
+
return lines
|
|
325
|
+
.map(line => {
|
|
326
|
+
if (line.startsWith("+ ")) return line.slice(2);
|
|
327
|
+
if (line.startsWith("+")) return line.slice(1);
|
|
328
|
+
return line;
|
|
329
|
+
})
|
|
330
|
+
.join("\n");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return content;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
interface UnifiedHunkHeader {
|
|
337
|
+
oldStartLine: number;
|
|
338
|
+
oldLineCount: number;
|
|
339
|
+
newStartLine: number;
|
|
340
|
+
newLineCount: number;
|
|
341
|
+
changeContext?: string;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function parseUnifiedHunkHeader(line: string): UnifiedHunkHeader | undefined {
|
|
345
|
+
const match = line.match(UNIFIED_HUNK_HEADER_REGEX);
|
|
346
|
+
if (!match) return undefined;
|
|
347
|
+
|
|
348
|
+
const oldStartLine = Number(match[1]);
|
|
349
|
+
const oldLineCount = match[2] ? Number(match[2]) : 1;
|
|
350
|
+
const newStartLine = Number(match[3]);
|
|
351
|
+
const newLineCount = match[4] ? Number(match[4]) : 1;
|
|
352
|
+
const changeContext = match[5]?.trim();
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
oldStartLine,
|
|
356
|
+
oldLineCount,
|
|
357
|
+
newStartLine,
|
|
358
|
+
newLineCount,
|
|
359
|
+
changeContext: changeContext && changeContext.length > 0 ? changeContext : undefined,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isUnifiedDiffMetadataLine(line: string): boolean {
|
|
364
|
+
return matchesTrimmedPrefix(
|
|
365
|
+
line,
|
|
366
|
+
DIFF_METADATA_PREFIXES.filter(prefix => !prefix.startsWith("*** ")),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
interface ParseHunkResult {
|
|
371
|
+
hunk: DiffHunk;
|
|
372
|
+
linesConsumed: number;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: boolean): ParseHunkResult {
|
|
376
|
+
if (lines.length === 0) {
|
|
377
|
+
throw new ParseError("Diff does not contain any lines", lineNumber);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const changeContexts: string[] = [];
|
|
381
|
+
let oldStartLine: number | undefined;
|
|
382
|
+
let newStartLine: number | undefined;
|
|
383
|
+
let startIndex: number;
|
|
384
|
+
|
|
385
|
+
const headerLine = lines[0];
|
|
386
|
+
const headerTrimmed = headerLine.trimEnd();
|
|
387
|
+
const isHeaderLine = headerLine.startsWith("@@");
|
|
388
|
+
const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined;
|
|
389
|
+
const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed);
|
|
390
|
+
|
|
391
|
+
if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) {
|
|
392
|
+
startIndex = 1;
|
|
393
|
+
} else if (unifiedHeader) {
|
|
394
|
+
if (unifiedHeader.oldStartLine < 1 || unifiedHeader.newStartLine < 1) {
|
|
395
|
+
throw new ParseError("Line numbers in @@ header must be >= 1", lineNumber);
|
|
396
|
+
}
|
|
397
|
+
if (unifiedHeader.changeContext) {
|
|
398
|
+
changeContexts.push(unifiedHeader.changeContext);
|
|
399
|
+
}
|
|
400
|
+
oldStartLine = unifiedHeader.oldStartLine;
|
|
401
|
+
newStartLine = unifiedHeader.newStartLine;
|
|
402
|
+
startIndex = 1;
|
|
403
|
+
} else if (isHeaderLine && headerTrimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
|
|
404
|
+
const contextValue = headerTrimmed.slice(CHANGE_CONTEXT_MARKER.length);
|
|
405
|
+
const trimmedContextValue = contextValue.trim();
|
|
406
|
+
const normalizedContextValue = trimmedContextValue.replace(/^@@\s*/u, "");
|
|
407
|
+
|
|
408
|
+
const lineHintMatch = normalizedContextValue.match(LINE_HINT_REGEX);
|
|
409
|
+
if (lineHintMatch) {
|
|
410
|
+
oldStartLine = Number(lineHintMatch[1]);
|
|
411
|
+
newStartLine = oldStartLine;
|
|
412
|
+
if (oldStartLine < 1) {
|
|
413
|
+
throw new ParseError("Line hint must be >= 1", lineNumber);
|
|
414
|
+
}
|
|
415
|
+
} else if (TOP_OF_FILE_REGEX.test(normalizedContextValue)) {
|
|
416
|
+
oldStartLine = 1;
|
|
417
|
+
newStartLine = 1;
|
|
418
|
+
} else if (trimmedContextValue.length > 0) {
|
|
419
|
+
changeContexts.push(contextValue);
|
|
420
|
+
}
|
|
421
|
+
startIndex = 1;
|
|
422
|
+
} else if (isHeaderLine) {
|
|
423
|
+
const contextValue = headerTrimmed.slice(2).trim();
|
|
424
|
+
if (contextValue.length > 0) {
|
|
425
|
+
changeContexts.push(contextValue);
|
|
426
|
+
}
|
|
427
|
+
startIndex = 1;
|
|
428
|
+
} else {
|
|
429
|
+
if (!allowMissingContext) {
|
|
430
|
+
throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber);
|
|
431
|
+
}
|
|
432
|
+
startIndex = 0;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (oldStartLine !== undefined && oldStartLine < 1) {
|
|
436
|
+
throw new ParseError(`Line numbers must be >= 1 (got ${oldStartLine})`, lineNumber);
|
|
437
|
+
}
|
|
438
|
+
if (newStartLine !== undefined && newStartLine < 1) {
|
|
439
|
+
throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
while (startIndex < lines.length) {
|
|
443
|
+
const nextLine = lines[startIndex];
|
|
444
|
+
if (!nextLine.startsWith("@@")) {
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
const trimmed = nextLine.trimEnd();
|
|
448
|
+
if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
|
|
449
|
+
const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length);
|
|
450
|
+
if (nestedContext.trim().length > 0) {
|
|
451
|
+
changeContexts.push(nestedContext);
|
|
452
|
+
}
|
|
453
|
+
startIndex++;
|
|
454
|
+
} else if (trimmed === EMPTY_CHANGE_CONTEXT_MARKER) {
|
|
455
|
+
startIndex++;
|
|
456
|
+
} else {
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (startIndex >= lines.length) {
|
|
462
|
+
throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const changeContext = changeContexts.length > 0 ? changeContexts.join("\n") : undefined;
|
|
466
|
+
|
|
467
|
+
const hunk: DiffHunk = {
|
|
468
|
+
changeContext,
|
|
469
|
+
oldStartLine,
|
|
470
|
+
newStartLine,
|
|
471
|
+
hasContextLines: false,
|
|
472
|
+
oldLines: [],
|
|
473
|
+
newLines: [],
|
|
474
|
+
isEndOfFile: false,
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
let parsedLines = 0;
|
|
478
|
+
|
|
479
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
480
|
+
const line = lines[i];
|
|
481
|
+
const trimmed = line.trim();
|
|
482
|
+
const nextLine = lines[i + 1];
|
|
483
|
+
|
|
484
|
+
if (line === "" && parsedLines > 0 && nextLine?.trimStart().startsWith("@@")) {
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) {
|
|
489
|
+
if (parsedLines === 0) {
|
|
490
|
+
throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
|
|
491
|
+
}
|
|
492
|
+
hunk.isEndOfFile = true;
|
|
493
|
+
parsedLines++;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (trimmed === "..." || trimmed === "…") {
|
|
498
|
+
hunk.hasContextLines = true;
|
|
499
|
+
parsedLines++;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const firstChar = line[0];
|
|
504
|
+
|
|
505
|
+
if (firstChar === undefined || firstChar === "") {
|
|
506
|
+
hunk.hasContextLines = true;
|
|
507
|
+
hunk.oldLines.push("");
|
|
508
|
+
hunk.newLines.push("");
|
|
509
|
+
} else if (firstChar === " ") {
|
|
510
|
+
hunk.hasContextLines = true;
|
|
511
|
+
hunk.oldLines.push(line.slice(1));
|
|
512
|
+
hunk.newLines.push(line.slice(1));
|
|
513
|
+
} else if (firstChar === "+") {
|
|
514
|
+
hunk.newLines.push(line.slice(1));
|
|
515
|
+
} else if (firstChar === "-") {
|
|
516
|
+
hunk.oldLines.push(line.slice(1));
|
|
517
|
+
} else if (!line.startsWith("@@")) {
|
|
518
|
+
hunk.hasContextLines = true;
|
|
519
|
+
hunk.oldLines.push(line);
|
|
520
|
+
hunk.newLines.push(line);
|
|
521
|
+
} else {
|
|
522
|
+
if (parsedLines === 0) {
|
|
523
|
+
throw new ParseError(
|
|
524
|
+
`Unexpected line in hunk: '${line}'. Lines must start with ' ' (context), '+' (add), or '-' (remove)`,
|
|
525
|
+
lineNumber + 1,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
parsedLines++;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (parsedLines === 0) {
|
|
534
|
+
throw new ParseError("Hunk does not contain any lines", lineNumber + startIndex);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
stripLineNumberPrefixes(hunk);
|
|
538
|
+
return { hunk, linesConsumed: parsedLines + startIndex };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function stripLineNumberPrefixes(hunk: DiffHunk): void {
|
|
542
|
+
const allLines = [...hunk.oldLines, ...hunk.newLines].filter(line => line.trim().length > 0);
|
|
543
|
+
if (allLines.length < 2) return;
|
|
544
|
+
|
|
545
|
+
const numberMatches = allLines
|
|
546
|
+
.map(line => line.match(/^\s*(\d{1,6})\s+(.+)$/u))
|
|
547
|
+
.filter((match): match is RegExpMatchArray => match !== null);
|
|
548
|
+
|
|
549
|
+
if (numberMatches.length < Math.max(2, Math.ceil(allLines.length * 0.6))) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const numbers = numberMatches.map(match => Number(match[1]));
|
|
554
|
+
let sequential = 0;
|
|
555
|
+
for (let i = 1; i < numbers.length; i++) {
|
|
556
|
+
if (numbers[i] === numbers[i - 1] + 1) {
|
|
557
|
+
sequential++;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (numbers.length >= 3 && sequential < Math.max(1, numbers.length - 2)) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const strip = (line: string): string => {
|
|
566
|
+
const match = line.match(/^\s*\d{1,6}\s+(.+)$/u);
|
|
567
|
+
return match ? match[1] : line;
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
hunk.oldLines = hunk.oldLines.map(strip);
|
|
571
|
+
hunk.newLines = hunk.newLines.map(strip);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function countMultiFileMarkers(diff: string): number {
|
|
575
|
+
const counts = new Map<string, number>();
|
|
576
|
+
const paths = new Set<string>();
|
|
577
|
+
const lines = diff.split("\n");
|
|
578
|
+
for (const line of lines) {
|
|
579
|
+
if (isDiffContentLine(line)) {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const trimmed = line.trim();
|
|
583
|
+
for (const marker of MULTI_FILE_MARKERS) {
|
|
584
|
+
if (trimmed.startsWith(marker)) {
|
|
585
|
+
const filePath = extractMarkerPath(trimmed);
|
|
586
|
+
if (filePath) {
|
|
587
|
+
paths.add(filePath);
|
|
588
|
+
}
|
|
589
|
+
counts.set(marker, (counts.get(marker) ?? 0) + 1);
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (paths.size > 0) {
|
|
595
|
+
return paths.size;
|
|
596
|
+
}
|
|
597
|
+
let maxCount = 0;
|
|
598
|
+
for (const count of counts.values()) {
|
|
599
|
+
if (count > maxCount) {
|
|
600
|
+
maxCount = count;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return maxCount;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function extractMarkerPath(line: string): string | undefined {
|
|
607
|
+
if (line.startsWith("diff --git ")) {
|
|
608
|
+
const parts = line.split(/\s+/);
|
|
609
|
+
const candidate = parts[3] ?? parts[2];
|
|
610
|
+
if (!candidate) return undefined;
|
|
611
|
+
return candidate.replace(/^(a|b)\//, "");
|
|
612
|
+
}
|
|
613
|
+
if (line.startsWith("*** Update File:")) {
|
|
614
|
+
return line.slice("*** Update File:".length).trim();
|
|
615
|
+
}
|
|
616
|
+
if (line.startsWith("*** Add File:")) {
|
|
617
|
+
return line.slice("*** Add File:".length).trim();
|
|
618
|
+
}
|
|
619
|
+
if (line.startsWith("*** Delete File:")) {
|
|
620
|
+
return line.slice("*** Delete File:".length).trim();
|
|
621
|
+
}
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function parseDiffHunks(diff: string): DiffHunk[] {
|
|
626
|
+
const multiFileCount = countMultiFileMarkers(diff);
|
|
627
|
+
if (multiFileCount > 1) {
|
|
628
|
+
throw new ApplyPatchError(
|
|
629
|
+
`Diff contains ${multiFileCount} file markers. Single-file patches cannot contain multi-file markers.`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const normalizedDiff = normalizeDiff(diff);
|
|
634
|
+
const lines = normalizedDiff.split("\n");
|
|
635
|
+
const hunks: DiffHunk[] = [];
|
|
636
|
+
let i = 0;
|
|
637
|
+
|
|
638
|
+
while (i < lines.length) {
|
|
639
|
+
const line = lines[i];
|
|
640
|
+
const trimmed = line.trim();
|
|
641
|
+
|
|
642
|
+
if (trimmed === "") {
|
|
643
|
+
i++;
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const firstChar = line[0];
|
|
648
|
+
const isDiffContent = firstChar === " " || firstChar === "+" || firstChar === "-";
|
|
649
|
+
if (!isDiffContent && isUnifiedDiffMetadataLine(trimmed)) {
|
|
650
|
+
i++;
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (trimmed.startsWith("@@") && lines.slice(i + 1).every(next => next.trim() === "")) {
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const { hunk, linesConsumed } = parseOneHunk(lines.slice(i), i + 1, true);
|
|
659
|
+
hunks.push(hunk);
|
|
660
|
+
i += linesConsumed;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return hunks;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Find and replace text in content using fuzzy matching.
|
|
668
|
+
*/
|
|
669
|
+
export function replaceText(content: string, oldText: string, newText: string, options: ReplaceOptions): ReplaceResult {
|
|
670
|
+
if (oldText.length === 0) {
|
|
671
|
+
throw new Error("oldText must not be empty.");
|
|
672
|
+
}
|
|
673
|
+
const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
|
|
674
|
+
let normalizedContent = normalizeToLF(content);
|
|
675
|
+
const normalizedOldText = normalizeToLF(oldText);
|
|
676
|
+
const normalizedNewText = normalizeToLF(newText);
|
|
677
|
+
let count = 0;
|
|
678
|
+
|
|
679
|
+
if (options.all) {
|
|
680
|
+
// Check for exact matches first
|
|
681
|
+
const exactCount = normalizedContent.split(normalizedOldText).length - 1;
|
|
682
|
+
if (exactCount > 0) {
|
|
683
|
+
return {
|
|
684
|
+
content: normalizedContent.split(normalizedOldText).join(normalizedNewText),
|
|
685
|
+
count: exactCount,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// No exact matches - try fuzzy matching iteratively
|
|
690
|
+
while (true) {
|
|
691
|
+
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
|
692
|
+
allowFuzzy: options.fuzzy,
|
|
693
|
+
threshold,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const shouldUseClosest =
|
|
697
|
+
options.fuzzy &&
|
|
698
|
+
matchOutcome.closest &&
|
|
699
|
+
matchOutcome.closest.confidence >= threshold &&
|
|
700
|
+
(matchOutcome.fuzzyMatches === undefined || matchOutcome.fuzzyMatches <= 1);
|
|
701
|
+
const match = matchOutcome.match || (shouldUseClosest ? matchOutcome.closest : undefined);
|
|
702
|
+
if (!match) {
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText);
|
|
707
|
+
if (adjustedNewText === match.actualText) {
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
normalizedContent =
|
|
711
|
+
normalizedContent.substring(0, match.startIndex) +
|
|
712
|
+
adjustedNewText +
|
|
713
|
+
normalizedContent.substring(match.startIndex + match.actualText.length);
|
|
714
|
+
count++;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return { content: normalizedContent, count };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Single replacement mode
|
|
721
|
+
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
|
722
|
+
allowFuzzy: options.fuzzy,
|
|
723
|
+
threshold,
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
|
727
|
+
throw new Error(formatOccurrenceMatchError(matchOutcome.occurrences, matchOutcome.occurrencePreviews));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!matchOutcome.match) {
|
|
731
|
+
return { content: normalizedContent, count: 0 };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const match = matchOutcome.match;
|
|
735
|
+
const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText);
|
|
736
|
+
normalizedContent =
|
|
737
|
+
normalizedContent.substring(0, match.startIndex) +
|
|
738
|
+
adjustedNewText +
|
|
739
|
+
normalizedContent.substring(match.startIndex + match.actualText.length);
|
|
740
|
+
|
|
741
|
+
return { content: normalizedContent, count: 1 };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
745
|
+
// Preview/Diff Computation
|
|
746
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Compute the diff for an edit operation without applying it.
|
|
750
|
+
* Used for preview rendering in the TUI before the tool executes.
|
|
751
|
+
*/
|
|
752
|
+
export async function computeEditDiff(
|
|
753
|
+
path: string,
|
|
754
|
+
oldText: string,
|
|
755
|
+
newText: string,
|
|
756
|
+
cwd: string,
|
|
757
|
+
fuzzy = true,
|
|
758
|
+
all = false,
|
|
759
|
+
threshold?: number,
|
|
760
|
+
): Promise<DiffResult | DiffError> {
|
|
761
|
+
if (oldText.length === 0) {
|
|
762
|
+
return { error: "oldText must not be empty." };
|
|
763
|
+
}
|
|
764
|
+
const absolutePath = resolveToCwd(path, cwd);
|
|
765
|
+
|
|
766
|
+
try {
|
|
767
|
+
let rawContent: string;
|
|
768
|
+
try {
|
|
769
|
+
rawContent = await readFileTextForDiff(path, absolutePath);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
772
|
+
return { error: message || `Unable to read ${path}` };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const { text: content } = stripBom(rawContent);
|
|
776
|
+
const normalizedContent = normalizeToLF(content);
|
|
777
|
+
const normalizedOldText = normalizeToLF(oldText);
|
|
778
|
+
const normalizedNewText = normalizeToLF(newText);
|
|
779
|
+
|
|
780
|
+
const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, {
|
|
781
|
+
fuzzy,
|
|
782
|
+
all,
|
|
783
|
+
threshold,
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
if (result.count === 0) {
|
|
787
|
+
// Get closest match for error message
|
|
788
|
+
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
|
789
|
+
allowFuzzy: fuzzy,
|
|
790
|
+
threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
|
794
|
+
return {
|
|
795
|
+
error: formatOccurrenceMatchError(matchOutcome.occurrences, matchOutcome.occurrencePreviews, path),
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
|
|
801
|
+
allowFuzzy: fuzzy,
|
|
802
|
+
threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
|
|
803
|
+
fuzzyMatches: matchOutcome.fuzzyMatches,
|
|
804
|
+
}),
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (normalizedContent === result.content) {
|
|
809
|
+
return {
|
|
810
|
+
error: `No changes would be made to ${path}. The replacement produces identical content.`,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return generateDiffString(normalizedContent, result.content);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
817
|
+
}
|
|
818
|
+
}
|