148 lines
4.6 KiB
Python
Executable File
148 lines
4.6 KiB
Python
Executable File
#!/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')
|