ollie/tools/file_edit

148 lines
4.6 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# description: Replace text in a file; must read with file_read first
# Args: <file_path> <old_string> <new_string> [replace_all=true|1|yes]
import sys
import os
import hashlib
import difflib
def marker_path(file_path):
sid = os.environ.get('OLLIE_SESSION_ID', '')
if not sid:
print("error: OLLIE_SESSION_ID is not set")
sys.exit(1)
h = hashlib.sha256(file_path.encode()).hexdigest()[:64]
return f"/tmp/ollie/{sid}/{h}"
def unified_diff(path, old, new):
old_lines = old.splitlines(keepends=True)
new_lines = new.splitlines(keepends=True)
raw = ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path))
# Replace leading +/- with Unicode lookalikes so only the colorizer's
# explicit patterns trigger, not incidental ASCII +/- in content.
out = []
for line in raw.splitlines(keepends=True):
if line.startswith('+'):
line = '+' + line[1:]
elif line.startswith('-'):
line = '−' + line[1:]
out.append(line)
return ''.join(out)
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"
if replace_all:
return content.replace(match, new_string), None
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"
return content.replace(match, new_string, 1), None
if len(sys.argv) < 4:
print("usage: file_edit <file_path> <old_string> <new_string> [replace_all=true|1|yes]")
sys.exit(1)
file_path = sys.argv[1]
old_string = sys.argv[2]
new_string = sys.argv[3]
_ra = sys.argv[4].lower() if len(sys.argv) > 4 else ''
_ra = _ra.split('=')[-1] # strip optional "replace_all=" prefix
replace_all = _ra in ('true', '1', 'yes')
if not os.path.isabs(file_path):
print(f"error: path must be absolute, got: {file_path}")
sys.exit(1)
if old_string == new_string:
print("error: old_string and new_string must be different")
sys.exit(1)
if not os.path.exists(file_path):
print(f"error: file not found: {file_path}")
sys.exit(1)
marker = marker_path(file_path)
if not os.path.exists(marker):
print(f"error: {file_path} must be read first with file_read")
sys.exit(1)
with open(marker, 'r', errors='replace') as f:
original = f.read()
new_content, err = robust_replace(original, old_string, new_string, replace_all)
if err:
print(f"error: {err}")
sys.exit(1)
try:
with open(marker, 'w') as f:
f.write(new_content)
with open(file_path, 'w') as f:
f.write(new_content)
except OSError as e:
print(f"error: {e}")
sys.exit(1)
try:
os.unlink(marker)
except OSError as e:
print(f"error: failed to remove marker: {e}")
sys.exit(1)
diff = unified_diff(file_path, original, new_content)
print(diff if diff else "(no changes)", end='' if diff else '\n')