Skip to content

Commit e27c8cb

Browse files
authored
Merge pull request #176 from editor-code-assistant/feature/eca-edit-file-smart-edit
Improve single-occurrence file edits
2 parents 9d2b80a + c6fdffa commit e27c8cb

File tree

9 files changed

+522
-61
lines changed

9 files changed

+522
-61
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
## Unreleased
4+
- Improved `eca_edit_file` to automatically handle whitespace and indentation differences in single-occurrence edits.
45

56
## 0.73.5
67

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
You must use your `eca_read_file` tool to get the file's exact contents before attempting an edit.
2-
This tool will error if you attempt an edit without reading the file.When crafting the `orginal_content`, you must match the original content from the `eca_read_file` tool output exactly, including all indentation (spaces/tabs) and newlines.
3-
Never include any part of the line number prefix in the `original_content` or `new_content`.The edit will FAIL if the `original_content` is not unique in the file. To resolve this, you must expand the `new_content` to include more surrounding lines of code or context to make it a unique block.
4-
ALWAYS prefer making small, targeted edits to existing files. Avoid replacing entire functions or large blocks of code in a single step unless absolutely necessary.
5-
To delete content, provide the content to be removed as the `original_content` and an empty string as the `new_content`.
6-
To prepend or append content, the `new_content` must contain both the new content and the original content from `old_string`.
1+
You must use `eca_read_file` to get the file's exact contents before attempting an edit.
2+
3+
## Best Practices
4+
5+
- Prefer small, targeted edits over replacing entire functions
6+
- Match content from `eca_read_file` as closely as possible
7+
- For single occurrence (default): include enough surrounding context to make the match unique
8+
- For multiple occurrences (`all_occurrences: true`): provide the exact literal content to replace.
9+
10+
## Common Issues
11+
12+
- "content not found": read the file again to verify the actual formatting
13+
- "ambiguous match" (single occurrence only): include more surrounding context
14+
- To delete content: use empty string as `new_content`
15+
- To prepend/append: `new_content` must contain both the new and the original content

src/eca/features/chat.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -723,7 +723,7 @@
723723
:origin origin
724724
:server server-name)})
725725
;; assert: In :executing or :stopping
726-
(let [state (get-tool-call-state @db* chat-id id)
726+
(let [state (get-tool-call-state @db* chat-id id)
727727
status (:status state)]
728728
(case status
729729
:executing (transition-tool-call! db* chat-ctx id :execution-end

src/eca/features/tools/filesystem.clj

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
[clojure.string :as string]
77
[eca.diff :as diff]
88
[eca.features.index :as f.index]
9+
[eca.features.tools.smart-edit :as smart-edit]
910
[eca.features.tools.text-match :as text-match]
1011
[eca.features.tools.util :as tools.util]
1112
[eca.logger :as logger]
@@ -225,26 +226,48 @@
225226
(format "Ambiguous match - content appears %d times in %s. Provide more specific context to identify the exact location."
226227
(:match-count result) path) :error)
227228

229+
(= (:error result) :conflict)
230+
(tools.util/single-text-content
231+
(format (str "File changed since it was read: %s. "
232+
"Re-read the file and retry the edit so we don't overwrite concurrent changes.")
233+
path)
234+
:error)
235+
228236
:else
229237
(tools.util/single-text-content (format "Failed to process %s" path) :error)))
230238

231-
(defn ^:private change-file [arguments {:keys [db]}]
239+
(defn ^:private apply-file-edit-strategy
240+
"Apply the appropriate edit strategy based on whether all occurrences should be replaced.
241+
- For all_occurrences=true: uses text-match (exact/normalized only) for predictability
242+
- For all_occurrences=false: uses smart-edit (multi-tier matching) for better handling"
243+
[file-content original-content new-content all? path]
244+
(if all?
245+
(text-match/apply-content-change-to-string file-content original-content new-content all? path)
246+
(smart-edit/apply-smart-edit file-content original-content new-content path)))
247+
248+
(defn ^:private edit-file [arguments {:keys [db]}]
232249
(or (tools.util/invalid-arguments arguments (concat (path-validations db)
233250
[["path" fs/readable? "File $path is not readable"]]))
234251
(let [path (get arguments "path")
235252
original-content (get arguments "original_content")
236253
new-content (get arguments "new_content")
237254
all? (boolean (get arguments "all_occurrences"))
238-
result (text-match/apply-content-change-to-file path original-content new-content all?)]
255+
initial-content (slurp path)
256+
result (apply-file-edit-strategy initial-content original-content new-content all? path)
257+
write! (fn [res]
258+
(spit path (:new-full-content res))
259+
(handle-file-change-result res path (format "Successfully replaced content in %s." path)))]
239260
(if (:new-full-content result)
240-
(do
241-
(spit path (:new-full-content result))
242-
(handle-file-change-result result path (format "Successfully replaced content in %s." path)))
261+
(let [current-content (slurp path)]
262+
(if (= current-content (:original-full-content result))
263+
(write! result)
264+
;; Optimistic retry once against latest content
265+
(let [retry (apply-file-edit-strategy current-content original-content new-content all? path)]
266+
(if (:new-full-content retry)
267+
(write! retry)
268+
(handle-file-change-result {:error :conflict} path nil)))))
243269
(handle-file-change-result result path nil)))))
244270

245-
(defn ^:private edit-file [arguments components]
246-
(change-file arguments components))
247-
248271
(defn ^:private preview-file-change [arguments {:keys [db]}]
249272
(or (tools.util/invalid-arguments arguments [["path" (partial allowed-path? db) (str "Access denied - path $path outside allowed directories: " (tools.util/workspace-roots-strs db))]])
250273
(let [path (get arguments "path")
@@ -254,7 +277,7 @@
254277
file-exists? (fs/exists? path)]
255278
(cond
256279
file-exists?
257-
(let [result (text-match/apply-content-change-to-file path original-content new-content all?)]
280+
(let [result (apply-file-edit-strategy (slurp path) original-content new-content all? path)]
258281
(handle-file-change-result result path
259282
(format "Change simulation completed for %s. Original file unchanged - preview only." path)))
260283

@@ -321,7 +344,7 @@
321344
"new_content" {:type "string"
322345
:description "The new content to replace the original content with"}
323346
"all_occurrences" {:type "boolean"
324-
:description "Whether to replace all occurences of the file or just the first one (default)"}}
347+
:description "Whether to replace all occurrences of the file or just the first one (default)"}}
325348
:required ["path" "original_content" "new_content"]}
326349
:handler #'edit-file
327350
:summary-fn (constantly "Editing file")}
@@ -372,7 +395,7 @@
372395
file-exists? (and path (fs/exists? path))]
373396
(cond
374397
(and file-exists? original-content new-content)
375-
(let [result (text-match/apply-content-change-to-file path original-content new-content all?)
398+
(let [result (apply-file-edit-strategy (slurp path) original-content new-content (boolean all?) path)
376399
original-full-content (:original-full-content result)]
377400
(when original-full-content
378401
(if-let [new-full-content (:new-full-content result)]
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
(ns eca.features.tools.smart-edit
2+
"Smart file editing with advanced matching strategies.
3+
4+
This namespace implements multi-tier matching for file edits:
5+
- Flexible matching (whitespace-agnostic with indentation preservation)
6+
- Regex matching (tokenized matching with flexible whitespace)
7+
8+
Note: This only supports SINGLE replacement (no multiple occurrences).
9+
For multiple replacements, use eca.features.tools.text-match instead."
10+
(:require [clojure.string :as string]
11+
[eca.features.tools.text-match :as text-match]
12+
[eca.logger :as logger])
13+
(:import
14+
[java.util.regex Pattern]))
15+
16+
(set! *warn-on-reflection* true)
17+
18+
;;; Flexible Matching (Whitespace-Agnostic)
19+
20+
(defn- try-flexible-match
21+
"Match content ignoring whitespace differences, preserving original indentation.
22+
Ambiguity prevention: if more than one region matches, return {:error :ambiguous}.
23+
Returns:
24+
- {:new-full-content ... :strategy :flexible} on exactly one match
25+
- {:error :ambiguous, :match-count n, :original-full-content content} if n>1
26+
- nil if no matches"
27+
[file-content original-content new-content path]
28+
(let [file-lines (vec (string/split-lines file-content))
29+
search-lines (string/split-lines original-content)
30+
search-lines-trimmed (mapv string/trim search-lines)
31+
new-lines (string/split-lines new-content)
32+
search-len (count search-lines)]
33+
(when (pos? search-len)
34+
(let [match-indexes (loop [idx 0 acc []]
35+
(if (<= (+ idx search-len) (count file-lines))
36+
(let [window (subvec file-lines idx (+ idx search-len))
37+
window-trimmed (mapv string/trim window)]
38+
(recur (inc idx)
39+
(if (= window-trimmed search-lines-trimmed)
40+
(conj acc idx)
41+
acc)))
42+
acc))
43+
cnt (count match-indexes)]
44+
(case cnt
45+
0 nil
46+
1 (let [idx (first match-indexes)
47+
window (subvec file-lines idx (+ idx search-len))
48+
;; Use indentation from the first non-blank line; fallback to first line
49+
indentation (->> window
50+
(drop-while string/blank?)
51+
first
52+
(or (first window))
53+
(text-match/detect-indentation))
54+
indented-new (text-match/apply-indentation (string/join "\n" new-lines) indentation)
55+
indented-new-lines (string/split-lines indented-new)
56+
result-lines (concat (take idx file-lines)
57+
indented-new-lines
58+
(drop (+ idx search-len) file-lines))]
59+
(logger/debug "Content matched using flexible matching for" path)
60+
{:original-full-content file-content
61+
:new-full-content (string/join "\n" result-lines)
62+
:strategy :flexible})
63+
(do (logger/debug "Flexible match ambiguous for" path "- matches:" cnt)
64+
{:error :ambiguous
65+
:match-count cnt
66+
:original-full-content file-content}))))))
67+
68+
;;; Regex Matching (Tokenized)
69+
70+
(defn- tokenize-by-delimiters
71+
"Tokenize a search string for flexible regex matching.
72+
Strategy (similar to Gemini CLI):
73+
- Insert spaces around common code delimiters so they become separate tokens
74+
- Split by any whitespace to get minimal tokens
75+
- Remove empty tokens
76+
Returns a vector of tokens."
77+
[s]
78+
(let [delims ["(" ")" ":" "[" "]" "{" "}" ">" "<" "=" "," ";"]
79+
spaced (when s (reduce (fn [acc d]
80+
(string/replace acc d (str " " d " ")))
81+
s delims))]
82+
(->> (or (some-> spaced (string/split #"\s+")) [])
83+
(remove string/blank?)
84+
vec)))
85+
86+
(defn- try-regex-match
87+
"Tokenized regex matching with ambiguity prevention.
88+
- Build a multiline pattern anchored at start-of-line with flexible \\s* between tokens
89+
- Count all matches across the file
90+
- If exactly one match, replace it (first only)
91+
- If >1, return {:error :ambiguous}
92+
- If 0, return nil"
93+
[file-content original-content new-content path]
94+
(let [tokens (tokenize-by-delimiters original-content)]
95+
(when (seq tokens)
96+
(let [escaped-tokens (map #(Pattern/quote %) tokens)
97+
pattern-str (str "(?m)^([ \\t]*)" (string/join "\\s*" escaped-tokens))
98+
pattern (re-pattern pattern-str)
99+
matches (re-seq pattern file-content)
100+
cnt (count matches)]
101+
(cond
102+
(= cnt 0) nil
103+
(> cnt 1) (do (logger/debug "Regex match ambiguous for" path "- matches:" cnt)
104+
{:error :ambiguous
105+
:match-count cnt
106+
:original-full-content file-content})
107+
:else (let [indentation (some-> matches first second)
108+
indented-new (text-match/apply-indentation new-content (or indentation ""))
109+
quoted (java.util.regex.Matcher/quoteReplacement indented-new)
110+
new-content-str (string/replace-first file-content pattern quoted)]
111+
(logger/debug "Content matched using regex matching for" path)
112+
{:original-full-content file-content
113+
:new-full-content new-content-str
114+
:strategy :regex}))))))
115+
116+
(defn apply-smart-edit
117+
"Apply smart edit with multi-tier matching.
118+
SINGLE REPLACEMENT ONLY - does not support multiple occurrences.
119+
120+
Matching order:
121+
1. Exact match (via text-match)
122+
2. Normalized match (via text-match)
123+
3. Flexible match (whitespace-agnostic)
124+
4. Regex match (tokenized)
125+
126+
Line ending style (CRLF vs LF) is automatically preserved."
127+
[file-content original-content new-content path]
128+
;; Detect original line ending
129+
(let [line-ending (text-match/detect-line-ending file-content)
130+
;; Normalize to LF for processing
131+
norm-file (text-match/normalize-to-lf file-content)
132+
norm-orig (text-match/normalize-to-lf original-content)
133+
norm-new (text-match/normalize-to-lf new-content)
134+
135+
;; Try text-match strategies first (exact + normalized)
136+
text-match-result (text-match/apply-content-change-to-string file-content original-content new-content false path)
137+
138+
;; Try advanced matching if text-match failed
139+
result (cond
140+
(:new-full-content text-match-result)
141+
text-match-result
142+
143+
(= :ambiguous (:error text-match-result))
144+
text-match-result
145+
146+
:else
147+
(or
148+
(try-flexible-match norm-file norm-orig norm-new path)
149+
(try-regex-match norm-file norm-orig norm-new path)
150+
text-match-result))]
151+
152+
;; Restore original line endings and trailing newline if successful,
153+
;; and ensure original-full-content reflects the exact pre-edit content
154+
(if (:new-full-content result)
155+
(-> result
156+
(assoc :original-full-content file-content)
157+
(update :new-full-content #(text-match/restore-trailing-newline file-content %))
158+
(update :new-full-content text-match/restore-line-ending line-ending))
159+
result)))

0 commit comments

Comments
 (0)