146 lines
5.2 KiB
Python
Executable File
146 lines
5.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# args_json: {"type":"object","required":["path","old_string","new_string"],"properties":{"path":{"type":"string","description":"Absolute path to the file to edit"},"old_string":{"type":"string","description":"Text to find (fuzzy whitespace matching)"},"new_string":{"type":"string","description":"Replacement text"},"replace_all":{"type":"string","description":"Set to true/1/yes to replace all occurrences"}}}
|
|
# ollie:prompt
|
|
# ## file_edit
|
|
#
|
|
# Replace text in a file. Has fuzzy matching (whitespace/indent flexible).
|
|
#
|
|
# **Args**: `path`, `old_string`, `new_string` (required); `replace_all` (optional)
|
|
#
|
|
# ```
|
|
# file_edit(path="/abs/path", old_string="old text", new_string="new text")
|
|
# file_edit(path="/abs/path", old_string="old text", new_string="new text", replace_all="true")
|
|
# ```
|
|
#
|
|
# **Constraints**: Absolute paths only. Errors if `old_string` matches multiple locations — add surrounding context to disambiguate, or use `replace_all=true`.
|
|
# ollie:end
|
|
|
|
import sys
|
|
import os
|
|
import difflib
|
|
|
|
sys.path.insert(0, os.environ.get('OLLIE_TOOLS_PATH', os.path.dirname(os.path.abspath(__file__))))
|
|
from _lib.args import parse_args
|
|
|
|
def format_diff(path, old, new):
|
|
old_lines = old.splitlines(keepends=True)
|
|
new_lines = new.splitlines(keepends=True)
|
|
diff = ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path))
|
|
if not diff:
|
|
return None
|
|
return f"```diff\n{diff}```"
|
|
|
|
def find_exact(content, find):
|
|
return find if find in content else None
|
|
|
|
def find_ws_normalized(content, find):
|
|
nf = ' '.join(find.split())
|
|
find_lines = find.split('\n')
|
|
n = len(find_lines)
|
|
lines = content.split('\n')
|
|
for i in range(len(lines) - n + 1):
|
|
block = '\n'.join(lines[i:i+n])
|
|
if ' '.join(block.split()) == nf:
|
|
return block
|
|
return None
|
|
|
|
def find_indent_flexible(content, find):
|
|
def deindent(text):
|
|
ls = text.split('\n')
|
|
min_indent = float('inf')
|
|
for l in ls:
|
|
stripped = l.lstrip(' \t')
|
|
if stripped:
|
|
min_indent = min(min_indent, len(l) - len(stripped))
|
|
if min_indent == float('inf'):
|
|
return text
|
|
return '\n'.join(l[min_indent:] if len(l) > min_indent else l.lstrip(' \t') for l in ls)
|
|
nf = deindent(find)
|
|
find_lines = find.split('\n')
|
|
n = len(find_lines)
|
|
lines = content.split('\n')
|
|
for i in range(len(lines) - n + 1):
|
|
block = '\n'.join(lines[i:i+n])
|
|
if deindent(block) == nf:
|
|
return block
|
|
return None
|
|
|
|
def find_trimmed_boundary(content, find):
|
|
trimmed = find.strip()
|
|
if trimmed == find:
|
|
return None
|
|
if trimmed in content:
|
|
return trimmed
|
|
find_lines = find.split('\n')
|
|
n = len(find_lines)
|
|
lines = content.split('\n')
|
|
for i in range(len(lines) - n + 1):
|
|
block = '\n'.join(lines[i:i+n])
|
|
if block.strip() == trimmed:
|
|
return block
|
|
return None
|
|
|
|
def robust_replace(content, old_string, new_string, replace_all):
|
|
match = (find_exact(content, old_string) or
|
|
find_ws_normalized(content, old_string) or
|
|
find_indent_flexible(content, old_string) or
|
|
find_trimmed_boundary(content, old_string))
|
|
if match is None:
|
|
return None, "old_string not found in file", False
|
|
if replace_all:
|
|
return content.replace(match, new_string), None, False
|
|
n = content.count(match)
|
|
if n > 1:
|
|
return None, f"old_string is not unique ({n} occurrences); use replace_all=true or provide more context", True
|
|
return content.replace(match, new_string, 1), None, False
|
|
|
|
args = parse_args()
|
|
file_path = args.require("path")
|
|
old_string = args.require("old_string")
|
|
new_string = args.require("new_string")
|
|
_ra = args.get("replace_all", "")
|
|
replace_all = _ra.lower() in ('true', '1', 'yes') if _ra else False
|
|
|
|
if not os.path.isabs(file_path):
|
|
print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false")
|
|
print(f"error: path must be absolute, got: {file_path}")
|
|
sys.exit(1)
|
|
if old_string == new_string:
|
|
print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false")
|
|
print("error: old_string and new_string must be different")
|
|
sys.exit(1)
|
|
if not os.path.exists(file_path):
|
|
print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false")
|
|
print(f"error: file not found: {file_path}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
original = f.read()
|
|
except UnicodeDecodeError:
|
|
print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false")
|
|
print(f"error: file is not valid UTF-8: {file_path}")
|
|
sys.exit(1)
|
|
|
|
new_content, err, retryable = robust_replace(original, old_string, new_string, replace_all)
|
|
if err:
|
|
print(f"STATUS=error STATE_CHANGED=false RETRYABLE={'true' if retryable else 'false'}")
|
|
print(f"error: {err}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
with open(file_path, 'w', encoding='utf-8') as f:
|
|
f.write(new_content)
|
|
except OSError as e:
|
|
print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false")
|
|
print(f"error: {e}")
|
|
sys.exit(1)
|
|
|
|
diff = format_diff(file_path, original, new_content)
|
|
if diff is None:
|
|
print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false")
|
|
print("(no changes)")
|
|
else:
|
|
print("STATUS=done STATE_CHANGED=true")
|
|
print(diff)
|