replace file tools with Go binaries and add tests
This commit is contained in:
parent
929b94e34d
commit
8567ddaa36
|
|
@ -1,142 +0,0 @@
|
|||
#!/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="<ABSOLUTE_PATH_FROM_PWD>", old_string="old text", new_string="new text")
|
||||
# file_edit(path="<ABSOLUTE_PATH_FROM_PWD>", 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.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)
|
||||
return ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path))
|
||||
|
||||
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 not diff:
|
||||
print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false")
|
||||
print("(no changes)")
|
||||
else:
|
||||
print("STATUS=done STATE_CHANGED=true")
|
||||
print(diff, end="" if diff.endswith("\n") else "\n")
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# ollie:parallel read
|
||||
# args_json: {"type":"object","required":["pattern"],"properties":{"pattern":{"type":"string","description":"Glob pattern (e.g. \"*.go\", \"**/*.ts\")"},"search_dir":{"type":"string","description":"Absolute search directory path (optional, defaults to cwd)"}}}
|
||||
# ollie:prompt
|
||||
# ## file_glob
|
||||
#
|
||||
# Find files by glob pattern, sorted by mtime (newest first).
|
||||
#
|
||||
# **Args**: `[pattern]` or `[pattern, search_dir]`
|
||||
#
|
||||
# - First arg: glob pattern (e.g. `"*.go"`, `"**/*.ts"`).
|
||||
# - Second arg (optional): absolute search directory. Defaults to cwd.
|
||||
#
|
||||
# ```
|
||||
# file_glob(pattern="*.go", search_dir="<ABSOLUTE_SEARCH_DIR>")
|
||||
# file_glob(pattern="**/*.go")
|
||||
# ```
|
||||
# ollie:end
|
||||
|
||||
source "$(dirname "$0")/_lib/args.sh"
|
||||
|
||||
pattern=$(arg_require "pattern")
|
||||
search_path=$(arg_get "search_dir" "$PWD")
|
||||
|
||||
if [ ! -d "$search_path" ]; then
|
||||
echo "STATUS=error"
|
||||
echo "error: path is not a directory: $search_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Collect file matches
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
file_out=$(rg --no-config --files -g "$pattern" "$search_path" 2>&1)
|
||||
rg_exit=$?
|
||||
if [ $rg_exit -gt 1 ]; then
|
||||
echo "STATUS=error"
|
||||
echo "error: $file_out"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Fallback: use find with glob pattern
|
||||
find_pattern="$pattern"
|
||||
find_pattern="${find_pattern##*\*\*/}"
|
||||
file_out=$(find "$search_path" -type f -name "$find_pattern" 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -z "$file_out" ]; then
|
||||
echo "STATUS=no_matches MATCH_COUNT=0"
|
||||
echo "no matches"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Sort by mtime descending
|
||||
sorted=$(echo "$file_out" \
|
||||
| xargs -d '\n' stat --printf '%Y\t%n\n' 2>/dev/null \
|
||||
| sort -rn \
|
||||
| cut -f2-)
|
||||
|
||||
if [ -z "$sorted" ]; then
|
||||
sorted="$file_out"
|
||||
fi
|
||||
|
||||
count=$(echo "$sorted" | wc -l | tr -d ' ')
|
||||
echo "STATUS=ok MATCH_COUNT=$count"
|
||||
echo "$sorted"
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# ollie:parallel read
|
||||
# args_json: {"type":"object","required":["pattern"],"properties":{"pattern":{"type":"string","description":"Regex pattern to search"},"path":{"type":"string","description":"Search directory (default: cwd)"},"mode":{"type":"string","description":"content|files_with_matches|count (default: content)"},"glob":{"type":"string","description":"File glob filter"},"type":{"type":"string","description":"File type filter (rg --type)"},"case_insensitive":{"type":"boolean","description":"Case-insensitive search"},"after":{"type":"integer","description":"Lines after match (-A)"},"before":{"type":"integer","description":"Lines before match (-B)"},"context":{"type":"integer","description":"Lines around match (-C)"},"multiline":{"type":"boolean","description":"Enable multiline matching"},"head":{"type":"integer","description":"Max results to return (default: 50)"},"offset":{"type":"integer","description":"Skip first N results"}}}
|
||||
# ollie:prompt
|
||||
# ## file_grep
|
||||
#
|
||||
# Search file contents with ripgrep. Capped at 50 matches by default.
|
||||
#
|
||||
# **Args**: `pattern` (required), plus optional named params.
|
||||
#
|
||||
# ```
|
||||
# file_grep(pattern="pattern", path="<ABSOLUTE_SEARCH_DIR>")
|
||||
# file_grep(pattern="TODO", path="src/", glob="*.go", case_insensitive=true)
|
||||
# file_grep(pattern="func main", path="$PWD")
|
||||
# ```
|
||||
#
|
||||
# - `path`: search directory (not a positional arg)
|
||||
# - `glob`: file glob filter
|
||||
# - `case_insensitive`: boolean
|
||||
#
|
||||
# **Params**: `path`, `mode` (content|files_with_matches|count), `glob`, `type`, `case_insensitive`, `before`, `after`, `context`, `multiline`, `head`, `offset`.
|
||||
# ollie:end
|
||||
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _lib.args import parse_args
|
||||
|
||||
args = parse_args()
|
||||
pattern = args.require("pattern")
|
||||
search_path = args.get("path", os.getcwd())
|
||||
mode = args.get("mode", "content")
|
||||
glob_pat = args.get("glob")
|
||||
type_filter = args.get("type")
|
||||
case_insensitive = args.get_bool("case_insensitive")
|
||||
after = args.get_int("after")
|
||||
before = args.get_int("before")
|
||||
context_lines = args.get_int("context")
|
||||
multiline = args.get_bool("multiline")
|
||||
head = args.get_int("head", 0)
|
||||
offset = args.get_int("offset", 0)
|
||||
|
||||
use_rg = shutil.which('rg') is not None
|
||||
|
||||
if use_rg:
|
||||
cmd = ['rg', '--no-config', '--color=never']
|
||||
|
||||
if mode == 'files_with_matches':
|
||||
cmd.append('-l')
|
||||
elif mode == 'count':
|
||||
cmd.append('--count')
|
||||
else:
|
||||
cmd.append('-n')
|
||||
|
||||
if case_insensitive:
|
||||
cmd.append('-i')
|
||||
if multiline:
|
||||
cmd += ['-U', '--multiline-dotall']
|
||||
if glob_pat:
|
||||
cmd += ['-g', glob_pat]
|
||||
if type_filter:
|
||||
cmd += ['-t', type_filter]
|
||||
if context_lines is not None:
|
||||
cmd += ['-C', str(context_lines)]
|
||||
if after is not None:
|
||||
cmd += ['-A', str(after)]
|
||||
if before is not None:
|
||||
cmd += ['-B', str(before)]
|
||||
|
||||
cmd += ['-e', pattern, search_path]
|
||||
else:
|
||||
cmd = ['grep', '-r', '--color=never']
|
||||
|
||||
if mode == 'files_with_matches':
|
||||
cmd.append('-l')
|
||||
elif mode == 'count':
|
||||
cmd.append('-c')
|
||||
else:
|
||||
cmd.append('-n')
|
||||
|
||||
if case_insensitive:
|
||||
cmd.append('-i')
|
||||
if glob_pat:
|
||||
cmd += ['--include=' + glob_pat]
|
||||
if context_lines is not None:
|
||||
cmd += ['-C', str(context_lines)]
|
||||
if after is not None:
|
||||
cmd += ['-A', str(after)]
|
||||
if before is not None:
|
||||
cmd += ['-B', str(before)]
|
||||
|
||||
cmd += ['-e', pattern, search_path]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 1:
|
||||
print("STATUS=no_matches MATCH_COUNT=0")
|
||||
print("no matches")
|
||||
sys.exit(0)
|
||||
if result.returncode > 1:
|
||||
print(f"STATUS=error")
|
||||
print(f"error: {result.stderr.strip() or f'search exited with {result.returncode}'}")
|
||||
sys.exit(1)
|
||||
|
||||
lines = result.stdout.splitlines()
|
||||
total = len(lines)
|
||||
|
||||
if offset:
|
||||
lines = lines[offset:]
|
||||
|
||||
max_matches = head if head else 50
|
||||
capped = len(lines) > max_matches
|
||||
if capped:
|
||||
lines = lines[:max_matches]
|
||||
|
||||
if mode == 'content':
|
||||
formatted = []
|
||||
for line in lines:
|
||||
if line == '--':
|
||||
formatted.append(line)
|
||||
continue
|
||||
for sep in (':', '-'):
|
||||
parts = line.split(sep, 2)
|
||||
if len(parts) == 3 and parts[1].isdigit():
|
||||
formatted.append(f"{parts[0]}:{parts[1]:>5}| {parts[2]}")
|
||||
break
|
||||
else:
|
||||
formatted.append(line)
|
||||
lines = formatted
|
||||
|
||||
print(f"STATUS=ok MATCH_COUNT={total}")
|
||||
print('\n'.join(lines))
|
||||
if capped:
|
||||
print(f"\n(showing {max_matches} of {total} matches; use --head=N or --offset=N for more)")
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# ollie:parallel read
|
||||
# ollie:tier cold
|
||||
# args_json: {"type":"object","required":["path"],"properties":{"path":{"type":"string","description":"Absolute path to file"},"start":{"type":"string","description":"1-indexed start line (default: 1)"},"end":{"type":"string","description":"1-indexed end line, inclusive (max 2000 line segment)"},"head":{"type":"string","description":"Read first N lines (alternative to start/end)"}}}
|
||||
# ollie:prompt
|
||||
# ## file_read
|
||||
#
|
||||
# Read a file with line numbers. Prefer segments over full reads.
|
||||
#
|
||||
# **Args**: `path` (required), `start`, `end`, or `head`
|
||||
#
|
||||
# - `start`/`end`: 1-indexed line numbers, inclusive. Must be strings. Max segment: 2000 lines.
|
||||
# - No start/end: reads up to 500 lines from line 1.
|
||||
# - `head`: read first N lines (alternative to start/end).
|
||||
#
|
||||
# ```
|
||||
# file_read(path="<ABSOLUTE_PATH_FROM_PWD>")
|
||||
# file_read(path="<ABSOLUTE_PATH_FROM_PWD>", start="100", end="200")
|
||||
# file_read(path="<ABSOLUTE_PATH_FROM_PWD>", head="50")
|
||||
# ```
|
||||
#
|
||||
# - `start`/`end` are strings even though they represent numbers.
|
||||
# - Use `head` as alternative to `start`/`end`.
|
||||
#
|
||||
# **Constraints**: Absolute paths only.
|
||||
# ollie:end
|
||||
|
||||
import sys
|
||||
import os
|
||||
from itertools import islice
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _lib.args import parse_args
|
||||
|
||||
MAX_LINES = 500
|
||||
MAX_SEGMENT = 2000
|
||||
MAX_LINE_LEN = 2000
|
||||
|
||||
args = parse_args()
|
||||
file_path = args.require("path")
|
||||
head = args.get_int("head")
|
||||
start = args.get_int("start", 1)
|
||||
end_arg = args.get_int("end")
|
||||
|
||||
if not os.path.isabs(file_path):
|
||||
print("STATUS=error")
|
||||
print(f"error: path must be absolute, got: {file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print("STATUS=not_found")
|
||||
print("(new file)")
|
||||
sys.exit(0)
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
print("STATUS=error")
|
||||
print(f"error: not a regular file: {file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
raw_head = f.read(8192)
|
||||
if b'\x00' in raw_head:
|
||||
IMAGE_SIGS = [
|
||||
b'\x89PNG\r\n\x1a\n', b'\xff\xd8\xff',
|
||||
b'GIF87a', b'GIF89a', b'RIFF',
|
||||
]
|
||||
is_image = any(raw_head.startswith(s) for s in IMAGE_SIGS)
|
||||
if is_image:
|
||||
print("STATUS=ok BINARY=true IMAGE=true")
|
||||
print("(image file — use image_read for visual content)")
|
||||
else:
|
||||
print("STATUS=ok BINARY=true")
|
||||
print("(binary file)")
|
||||
sys.exit(0)
|
||||
|
||||
# Count total lines
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
total_lines = sum(1 for _ in f)
|
||||
except UnicodeDecodeError:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
total_lines = sum(1 for _ in f)
|
||||
|
||||
start = max(1, start)
|
||||
if head:
|
||||
end = start + head - 1
|
||||
elif end_arg is None:
|
||||
end = start + MAX_LINES - 1
|
||||
else:
|
||||
end = min(end_arg, start + MAX_SEGMENT - 1)
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for _ in islice(f, start - 1):
|
||||
pass
|
||||
segment_lines = list(islice(f, end - start + 1))
|
||||
except UnicodeDecodeError:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
for _ in islice(f, start - 1):
|
||||
pass
|
||||
segment_lines = list(islice(f, end - start + 1))
|
||||
|
||||
if not segment_lines:
|
||||
if start == 1:
|
||||
print(f"STATUS=ok LINES=0 TOTAL_LINES={total_lines} TRUNCATED=false EMPTY=true")
|
||||
print("(empty file)")
|
||||
else:
|
||||
print(f"STATUS=ok LINES=0 TOTAL_LINES={total_lines} TRUNCATED=false EMPTY=true")
|
||||
print("(no lines in range)")
|
||||
sys.exit(0)
|
||||
|
||||
out = []
|
||||
for i, line in enumerate(segment_lines):
|
||||
lineno = start + i
|
||||
text = line.rstrip('\n')
|
||||
if len(text) > MAX_LINE_LEN:
|
||||
text = text[:MAX_LINE_LEN] + '...'
|
||||
out.append(f"{lineno:>6}\t{text}")
|
||||
|
||||
last_line_shown = start + len(out) - 1
|
||||
truncated = last_line_shown < total_lines
|
||||
print(f"STATUS=ok LINES={len(out)} TOTAL_LINES={total_lines} TRUNCATED={str(truncated).lower()}")
|
||||
print('\n'.join(out))
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# args_json: {"type":"object","required":["path","content"],"properties":{"path":{"type":"string","description":"Absolute path to create or overwrite"},"content":{"type":"string","description":"File content to write"}}}
|
||||
# ollie:prompt
|
||||
# ## file_write
|
||||
#
|
||||
# Create or overwrite a file. Produces a unified diff.
|
||||
#
|
||||
# **Args**: `[path, content]`
|
||||
#
|
||||
# ```
|
||||
# file_write(path="<ABSOLUTE_PATH_FROM_PWD>", content="file content here")
|
||||
# ```
|
||||
#
|
||||
# **Constraints**: Absolute paths only. Parent directory must exist.
|
||||
# ollie:end
|
||||
|
||||
import sys
|
||||
import os
|
||||
import difflib
|
||||
|
||||
sys.path.insert(0, 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)
|
||||
return ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path))
|
||||
|
||||
args = parse_args()
|
||||
file_path = args.require("path")
|
||||
content = args.require("content")
|
||||
|
||||
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)
|
||||
|
||||
parent = os.path.dirname(file_path)
|
||||
if parent and not os.path.isdir(parent):
|
||||
print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false")
|
||||
print(f"error: parent directory does not exist: {parent}")
|
||||
sys.exit(1)
|
||||
|
||||
old_content = ""
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
old_content = f.read()
|
||||
except UnicodeDecodeError:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
old_content = f.read()
|
||||
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(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, old_content, content)
|
||||
if not diff:
|
||||
print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false")
|
||||
print("(no changes)")
|
||||
else:
|
||||
print("STATUS=done STATE_CHANGED=true")
|
||||
print(diff, end="" if diff.endswith("\n") else "\n")
|
||||
23
justfile
23
justfile
|
|
@ -22,9 +22,9 @@ kf5: build-kf5 install-kf5
|
|||
|
||||
# === Build targets ===
|
||||
|
||||
build: core ninep lib9p toolsrv tools-web code-tools lsp-tools kde
|
||||
build: core ninep lib9p toolsrv tools-web code-tools file-tools lsp-tools kde
|
||||
|
||||
build-kf5: core ninep lib9p toolsrv kde-kf5
|
||||
build-kf5: core ninep lib9p toolsrv file-tools kde-kf5
|
||||
|
||||
core:
|
||||
@echo === Building ollie-core ===
|
||||
|
|
@ -81,6 +81,15 @@ code-tools:
|
|||
go build -o {{cfg}}/tools/code_dependencies ./tools/codeintel/cmd/code_dependencies/
|
||||
install -m644 ./data/tools/code_dependencies.meta {{cfg}}/tools/code_dependencies.meta
|
||||
|
||||
file-tools:
|
||||
@echo === Building Go file tools ===
|
||||
mkdir -p {{cfg}}/tools
|
||||
go build -o {{cfg}}/tools/file_read ./tools/filetools/cmd/file_read/
|
||||
go build -o {{cfg}}/tools/file_write ./tools/filetools/cmd/file_write/
|
||||
go build -o {{cfg}}/tools/file_edit ./tools/filetools/cmd/file_edit/
|
||||
go build -o {{cfg}}/tools/file_glob ./tools/filetools/cmd/file_glob/
|
||||
go build -o {{cfg}}/tools/file_grep ./tools/filetools/cmd/file_grep/
|
||||
|
||||
tools-web:
|
||||
@echo === Building web tools ===
|
||||
mkdir -p {{cfg}}/tools
|
||||
|
|
@ -168,11 +177,11 @@ install-data:
|
|||
install -m755 data/tools/browser_screencap {{cfg}}/tools/browser_screencap
|
||||
install -m755 data/tools/reasoning_think {{cfg}}/tools/reasoning_think
|
||||
install -m755 data/tools/shell {{cfg}}/tools/shell
|
||||
install -m755 data/tools/file_edit {{cfg}}/tools/file_edit
|
||||
install -m755 data/tools/file_glob {{cfg}}/tools/file_glob
|
||||
install -m755 data/tools/file_grep {{cfg}}/tools/file_grep
|
||||
install -m755 data/tools/file_read {{cfg}}/tools/file_read
|
||||
install -m755 data/tools/file_write {{cfg}}/tools/file_write
|
||||
install -m755 {{cfg}}/tools/file_edit {{cfg}}/tools/file_edit
|
||||
install -m755 {{cfg}}/tools/file_glob {{cfg}}/tools/file_glob
|
||||
install -m755 {{cfg}}/tools/file_grep {{cfg}}/tools/file_grep
|
||||
install -m755 {{cfg}}/tools/file_read {{cfg}}/tools/file_read
|
||||
install -m755 {{cfg}}/tools/file_write {{cfg}}/tools/file_write
|
||||
install -m755 data/tools/gui_accessibility {{cfg}}/tools/gui_accessibility
|
||||
install -m755 data/tools/gui_activities {{cfg}}/tools/gui_activities
|
||||
install -m755 data/tools/gui_apps {{cfg}}/tools/gui_apps
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package codeintel
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInterpolate(t *testing.T) {
|
||||
captures := map[string]string{"name": "Server", "value": "x + y"}
|
||||
|
|
@ -15,3 +19,39 @@ func TestInterpolate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageFor(t *testing.T) {
|
||||
for _, tc := range []struct{ path, name, want string }{{"x.go", "", "go"}, {"x.tsx", "", "tsx"}, {"x.yml", "", "yaml"}, {"x.go", "python", "python"}, {"x.unknown", "", ""}} {
|
||||
l, ok := LanguageFor(tc.path, tc.name)
|
||||
if (tc.want != "") != ok || ok && l.Name != tc.want {
|
||||
t.Errorf("LanguageFor(%q,%q)=%q,%v", tc.path, tc.name, l.Name, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestQueryAndDependencies(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
p := d + "/main.go"
|
||||
src := "package main\nimport \"fmt\"\nfunc Hello() { fmt.Println(\"x\") }\n"
|
||||
if err := os.WriteFile(p, []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cs, err := Query(p, "", "(function_declaration name: (identifier) @name)")
|
||||
if err != nil || len(cs) != 1 || cs[0].Text != "Hello" {
|
||||
t.Fatalf("query=%+v err=%v", cs, err)
|
||||
}
|
||||
deps, err := Dependencies(p, "")
|
||||
if err != nil || len(deps) == 0 || !strings.Contains(deps[0], "import") {
|
||||
t.Fatalf("deps=%v err=%v", deps, err)
|
||||
}
|
||||
}
|
||||
func TestReplaceMatches(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
p := d + "/main.go"
|
||||
if err := os.WriteFile(p, []byte("package main\nfunc A() {}\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ReplaceMatches(p, "", "(identifier) @match", "$match")
|
||||
if err != nil || !strings.Contains(string(out), "func A") {
|
||||
t.Fatalf("replace=%q err=%v", out, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"ollie/tools/filetools/internal/filetools"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() { run(filetools.Edit) }
|
||||
func run(fn func(map[string]any) (string, error)) {
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(os.Stdin).Decode(&m); err != nil {
|
||||
fail("invalid JSON input: %v", err)
|
||||
}
|
||||
out, err := fn(m)
|
||||
if err != nil {
|
||||
if out != "" {
|
||||
fmt.Fprintln(os.Stdout, out)
|
||||
}
|
||||
fail("%v", err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
}
|
||||
func fail(f string, a ...any) { fmt.Fprintf(os.Stderr, "error: "+f+"\n", a...); os.Exit(1) }
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"ollie/tools/filetools/internal/filetools"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() { run(filetools.Glob) }
|
||||
func run(fn func(map[string]any) (string, error)) {
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(os.Stdin).Decode(&m); err != nil {
|
||||
fail("invalid JSON input: %v", err)
|
||||
}
|
||||
out, err := fn(m)
|
||||
if err != nil {
|
||||
if out != "" {
|
||||
fmt.Fprintln(os.Stdout, out)
|
||||
}
|
||||
fail("%v", err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
}
|
||||
func fail(f string, a ...any) { fmt.Fprintf(os.Stderr, "error: "+f+"\n", a...); os.Exit(1) }
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"ollie/tools/filetools/internal/filetools"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() { run(filetools.Grep) }
|
||||
func run(fn func(map[string]any) (string, error)) {
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(os.Stdin).Decode(&m); err != nil {
|
||||
fail("invalid JSON input: %v", err)
|
||||
}
|
||||
out, err := fn(m)
|
||||
if err != nil {
|
||||
if out != "" {
|
||||
fmt.Fprintln(os.Stdout, out)
|
||||
}
|
||||
fail("%v", err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
}
|
||||
func fail(f string, a ...any) { fmt.Fprintf(os.Stderr, "error: "+f+"\n", a...); os.Exit(1) }
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"ollie/tools/filetools/internal/filetools"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() { run(filetools.Read) }
|
||||
func run(fn func(map[string]any) (string, error)) {
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(os.Stdin).Decode(&m); err != nil {
|
||||
fail("invalid JSON input: %v", err)
|
||||
}
|
||||
out, err := fn(m)
|
||||
if err != nil {
|
||||
if out != "" {
|
||||
fmt.Fprintln(os.Stdout, out)
|
||||
}
|
||||
fail("%v", err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
}
|
||||
func fail(f string, a ...any) { fmt.Fprintf(os.Stderr, "error: "+f+"\n", a...); os.Exit(1) }
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"ollie/tools/filetools/internal/filetools"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() { run(filetools.Write) }
|
||||
func run(fn func(map[string]any) (string, error)) {
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(os.Stdin).Decode(&m); err != nil {
|
||||
fail("invalid JSON input: %v", err)
|
||||
}
|
||||
out, err := fn(m)
|
||||
if err != nil {
|
||||
if out != "" {
|
||||
fmt.Fprintln(os.Stdout, out)
|
||||
}
|
||||
fail("%v", err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
}
|
||||
func fail(f string, a ...any) { fmt.Fprintf(os.Stderr, "error: "+f+"\n", a...); os.Exit(1) }
|
||||
|
|
@ -0,0 +1,453 @@
|
|||
package filetools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxLines = 500
|
||||
MaxSegment = 2000
|
||||
MaxLineLen = 2000
|
||||
)
|
||||
|
||||
func Arg(m map[string]any, key string) (string, error) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing required argument %q; provide it in the JSON object", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("argument %q must be a string, got %T", key, v)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func Int(m map[string]any, key string, def *int) (*int, error) {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
var s string
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
s = x
|
||||
case float64:
|
||||
s = strconv.Itoa(int(x))
|
||||
default:
|
||||
return nil, fmt.Errorf("argument %q must be an integer, got %T", key, v)
|
||||
}
|
||||
n, e := strconv.Atoi(s)
|
||||
if e != nil {
|
||||
return nil, fmt.Errorf("argument %q must be a valid integer, got %q", key, s)
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
func Bool(m map[string]any, key string) (bool, error) {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return false, nil
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x, nil
|
||||
case string:
|
||||
return strings.EqualFold(x, "true") || x == "1" || strings.EqualFold(x, "yes"), nil
|
||||
default:
|
||||
return false, fmt.Errorf("argument %q must be boolean, got %T", key, v)
|
||||
}
|
||||
}
|
||||
func Read(m map[string]any) (string, error) {
|
||||
p, e := Arg(m, "path")
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
return "", fmt.Errorf("path must be absolute; got %q. Pass an absolute path.", p)
|
||||
}
|
||||
st, e := os.Stat(p)
|
||||
if os.IsNotExist(e) {
|
||||
return "STATUS=not_found\n(new file)", nil
|
||||
}
|
||||
if e != nil {
|
||||
return "", fmt.Errorf("stat %q: %w", p, e)
|
||||
}
|
||||
if !st.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("path is not a regular file: %q", p)
|
||||
}
|
||||
raw, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
return "", fmt.Errorf("read %q: %w", p, e)
|
||||
}
|
||||
if bytes.IndexByte(raw, 0) >= 0 {
|
||||
image := bytes.HasPrefix(raw, []byte("\x89PNG\r\n\x1a\n")) || bytes.HasPrefix(raw, []byte("\xff\xd8\xff")) || bytes.HasPrefix(raw, []byte("GIF87a")) || bytes.HasPrefix(raw, []byte("GIF89a")) || bytes.HasPrefix(raw, []byte("RIFF"))
|
||||
if image {
|
||||
return "STATUS=ok BINARY=true IMAGE=true\n(image file — use image_read for visual content)", nil
|
||||
}
|
||||
return "STATUS=ok BINARY=true\n(binary file)", nil
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
total := len(lines)
|
||||
start := 1
|
||||
sp, e := Int(m, "start", nil)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if sp != nil {
|
||||
start = *sp
|
||||
}
|
||||
if start < 1 {
|
||||
start = 1
|
||||
}
|
||||
hp, e := Int(m, "head", nil)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
ep, e := Int(m, "end", nil)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
end := start + MaxLines - 1
|
||||
if hp != nil {
|
||||
if *hp < 0 {
|
||||
return "", fmt.Errorf("argument %q must be non-negative", "head")
|
||||
}
|
||||
end = start + *hp - 1
|
||||
} else if ep != nil {
|
||||
end = *ep
|
||||
if end > start+MaxSegment-1 {
|
||||
end = start + MaxSegment - 1
|
||||
}
|
||||
}
|
||||
if end < start {
|
||||
return fmt.Sprintf("STATUS=ok LINES=0 TOTAL_LINES=%d TRUNCATED=false EMPTY=true\n(no lines in range)", total), nil
|
||||
}
|
||||
from := start - 1
|
||||
if from > total {
|
||||
from = total
|
||||
}
|
||||
to := end
|
||||
if to > total {
|
||||
to = total
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := from; i < to; i++ {
|
||||
line := lines[i]
|
||||
if len(line) > MaxLineLen {
|
||||
line = line[:MaxLineLen] + "..."
|
||||
}
|
||||
fmt.Fprintf(&b, "%6d\t%s\n", i+1, line)
|
||||
}
|
||||
shown := to - from
|
||||
trunc := to < total
|
||||
status := fmt.Sprintf("STATUS=ok LINES=%d TOTAL_LINES=%d TRUNCATED=%t", shown, total, trunc)
|
||||
if shown == 0 {
|
||||
status += " EMPTY=true\n"
|
||||
if start == 1 {
|
||||
status += "(empty file)"
|
||||
} else {
|
||||
status += "(no lines in range)"
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
return status + "\n" + strings.TrimSuffix(b.String(), "\n"), nil
|
||||
}
|
||||
func Diff(path, old, new string) string {
|
||||
a := strings.SplitAfter(old, "\n")
|
||||
b := strings.SplitAfter(new, "\n")
|
||||
var out strings.Builder
|
||||
fmt.Fprintf(&out, "--- %s\n+++ %s\n", path, path)
|
||||
for _, x := range a {
|
||||
fmt.Fprintf(&out, "-%s", x)
|
||||
}
|
||||
for _, x := range b {
|
||||
fmt.Fprintf(&out, "+%s", x)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
func Edit(m map[string]any) (string, error) {
|
||||
p, e := Arg(m, "path")
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
old, e := Arg(m, "old_string")
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
newv, e := Arg(m, "new_string")
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
return "", fmt.Errorf("path must be absolute; got %q", p)
|
||||
}
|
||||
if old == newv {
|
||||
return "STATUS=no_change STATE_CHANGED=false RETRYABLE=false\nerror: old_string and new_string must be different", errors.New("old_string and new_string must be different")
|
||||
}
|
||||
data, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
return "", fmt.Errorf("read %q before edit: %w", p, e)
|
||||
}
|
||||
content := string(data)
|
||||
match := old
|
||||
if !strings.Contains(content, match) {
|
||||
norm := strings.Join(strings.Fields(old), " ")
|
||||
lines := strings.Split(content, "\n")
|
||||
for i := 0; i+len(strings.Split(old, "\n")) <= len(lines); i++ {
|
||||
block := strings.Join(lines[i:i+len(strings.Split(old, "\n"))], "\n")
|
||||
if strings.Join(strings.Fields(block), " ") == norm {
|
||||
match = block
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(content, match) {
|
||||
return "", fmt.Errorf("old_string not found in %q; provide text that exists, including enough surrounding context", p)
|
||||
}
|
||||
ra, e := Bool(m, "replace_all")
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
count := strings.Count(content, match)
|
||||
if count > 1 && !ra {
|
||||
return "", fmt.Errorf("old_string matches %d locations in %q; provide more context or set replace_all=true", count, p)
|
||||
}
|
||||
out := content
|
||||
if ra {
|
||||
out = strings.ReplaceAll(content, match, newv)
|
||||
} else {
|
||||
out = strings.Replace(content, match, newv, 1)
|
||||
}
|
||||
if e = os.WriteFile(p, []byte(out), 0644); e != nil {
|
||||
return "", fmt.Errorf("write edited file %q: %w", p, e)
|
||||
}
|
||||
return "STATUS=done STATE_CHANGED=true\n" + Diff(p, content, out), nil
|
||||
}
|
||||
func Write(m map[string]any) (string, error) {
|
||||
p, e := Arg(m, "path")
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
content, e := Arg(m, "content")
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
return "", fmt.Errorf("path must be absolute; got %q", p)
|
||||
}
|
||||
parent := filepath.Dir(p)
|
||||
if st, e := os.Stat(parent); e != nil || !st.IsDir() {
|
||||
return "", fmt.Errorf("parent directory does not exist: %q; create it before writing", parent)
|
||||
}
|
||||
old := []byte{}
|
||||
if data, e := os.ReadFile(p); e == nil {
|
||||
old = data
|
||||
} else if !os.IsNotExist(e) {
|
||||
return "", fmt.Errorf("read existing file %q: %w", p, e)
|
||||
}
|
||||
if e = os.WriteFile(p, []byte(content), 0644); e != nil {
|
||||
return "", fmt.Errorf("write %q: %w", p, e)
|
||||
}
|
||||
if string(old) == content {
|
||||
return "STATUS=no_change STATE_CHANGED=false RETRYABLE=false\n(no changes)", nil
|
||||
}
|
||||
return "STATUS=done STATE_CHANGED=true\n" + Diff(p, string(old), content), nil
|
||||
}
|
||||
func matchGlob(pattern, name string) bool {
|
||||
pattern = filepath.ToSlash(pattern)
|
||||
name = filepath.ToSlash(name)
|
||||
if strings.HasPrefix(pattern, "**/") {
|
||||
pattern = strings.TrimPrefix(pattern, "**/")
|
||||
for candidate := name; ; {
|
||||
if ok, _ := filepath.Match(pattern, candidate); ok {
|
||||
return true
|
||||
}
|
||||
i := strings.IndexByte(candidate, '/')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
candidate = candidate[i+1:]
|
||||
}
|
||||
}
|
||||
ok, _ := filepath.Match(pattern, name)
|
||||
return ok
|
||||
}
|
||||
func Glob(m map[string]any) (string, error) {
|
||||
pat, e := Arg(m, "pattern")
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
root := ""
|
||||
if v, ok := m["search_dir"]; ok {
|
||||
var okType bool
|
||||
root, okType = v.(string)
|
||||
if !okType {
|
||||
return "", fmt.Errorf("argument %q must be a string", "search_dir")
|
||||
}
|
||||
} else {
|
||||
root, e = os.Getwd()
|
||||
}
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if !filepath.IsAbs(root) {
|
||||
return "", fmt.Errorf("search_dir must be absolute; got %q", root)
|
||||
}
|
||||
if st, e := os.Stat(root); e != nil || !st.IsDir() {
|
||||
return "", fmt.Errorf("search_dir is not a directory: %q", root)
|
||||
}
|
||||
var paths []string
|
||||
e = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("walk %q: %w", p, err)
|
||||
}
|
||||
if d.IsDir() {
|
||||
if p != root && (d.Name() == ".git" || d.Name() == "node_modules" || d.Name() == "vendor") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
ok := matchGlob(pat, rel)
|
||||
if !ok {
|
||||
ok = matchGlob(pat, d.Name())
|
||||
}
|
||||
if ok {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
sort.Slice(paths, func(i, j int) bool {
|
||||
a, _ := os.Stat(paths[i])
|
||||
b, _ := os.Stat(paths[j])
|
||||
return a.ModTime().After(b.ModTime())
|
||||
})
|
||||
if len(paths) == 0 {
|
||||
return "STATUS=no_matches MATCH_COUNT=0\nno matches", nil
|
||||
}
|
||||
return fmt.Sprintf("STATUS=ok MATCH_COUNT=%d\n%s", len(paths), strings.Join(paths, "\n")), nil
|
||||
}
|
||||
func Grep(m map[string]any) (string, error) {
|
||||
pat, e := Arg(m, "pattern")
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
root := os.Getenv("PWD")
|
||||
if root == "" {
|
||||
root, _ = os.Getwd()
|
||||
}
|
||||
if v, ok := m["path"]; ok {
|
||||
var okType bool
|
||||
root, okType = v.(string)
|
||||
if !okType {
|
||||
return "", fmt.Errorf("argument %q must be a string", "path")
|
||||
}
|
||||
}
|
||||
if !filepath.IsAbs(root) {
|
||||
return "", fmt.Errorf("path must be absolute; got %q", root)
|
||||
}
|
||||
repat := pat
|
||||
if ci, _ := Bool(m, "case_insensitive"); ci {
|
||||
repat = "(?i)" + repat
|
||||
}
|
||||
re, e := regexp.Compile(repat)
|
||||
if e != nil {
|
||||
return "", fmt.Errorf("invalid regular expression %q: %w; fix the pattern and retry", pat, e)
|
||||
}
|
||||
mode := "content"
|
||||
if v, ok := m["mode"].(string); ok {
|
||||
mode = v
|
||||
}
|
||||
if mode != "content" && mode != "files_with_matches" && mode != "count" {
|
||||
return "", fmt.Errorf("mode must be content, files_with_matches, or count; got %q", mode)
|
||||
}
|
||||
head := 50
|
||||
if v, e := Int(m, "head", nil); e != nil {
|
||||
return "", e
|
||||
} else if v != nil {
|
||||
head = *v
|
||||
}
|
||||
offset := 0
|
||||
if v, e := Int(m, "offset", nil); e != nil {
|
||||
return "", e
|
||||
} else if v != nil {
|
||||
offset = *v
|
||||
}
|
||||
var results []string
|
||||
e = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if p != root && (d.Name() == ".git" || d.Name() == "node_modules" || d.Name() == "vendor") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if g, ok := m["glob"].(string); ok && g != "" {
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
good := matchGlob(g, rel)
|
||||
if !good {
|
||||
good = matchGlob(g, d.Name())
|
||||
}
|
||||
if !good {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
data, er := os.ReadFile(p)
|
||||
if er != nil {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
matches := 0
|
||||
for i, line := range lines {
|
||||
if re.MatchString(line) {
|
||||
matches++
|
||||
if mode == "content" {
|
||||
results = append(results, fmt.Sprintf("%s:%d:%s", p, i+1, line))
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches > 0 {
|
||||
if mode == "files_with_matches" {
|
||||
results = append(results, p)
|
||||
} else if mode == "count" {
|
||||
results = append(results, fmt.Sprintf("%s:%d", p, matches))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if e != nil {
|
||||
return "", fmt.Errorf("search %q: %w", root, e)
|
||||
}
|
||||
if offset < 0 || offset > len(results) {
|
||||
return "", fmt.Errorf("offset %d is outside result range 0..%d", offset, len(results))
|
||||
}
|
||||
results = results[offset:]
|
||||
total := len(results)
|
||||
if total == 0 {
|
||||
return "STATUS=no_matches MATCH_COUNT=0\nno matches", nil
|
||||
}
|
||||
shown := results
|
||||
if head >= 0 && len(shown) > head {
|
||||
shown = shown[:head]
|
||||
}
|
||||
out := fmt.Sprintf("STATUS=ok MATCH_COUNT=%d\n%s", total, strings.Join(shown, "\n"))
|
||||
if len(shown) < total {
|
||||
out += fmt.Sprintf("\n\n(showing %d of %d matches; use --head=N or --offset=N for more)", len(shown), total)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package filetools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadRangesAndErrors(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
p := filepath.Join(d, "x.txt")
|
||||
if err := os.WriteFile(p, []byte("one\ntwo\nthree\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Read(map[string]any{"path": p, "start": "2", "end": "3"})
|
||||
if err != nil || !strings.Contains(out, "2\ttwo") || !strings.Contains(out, "3\tthree") {
|
||||
t.Fatalf("read=%q err=%v", out, err)
|
||||
}
|
||||
if _, err = Read(map[string]any{"path": "relative"}); err == nil || !strings.Contains(err.Error(), "absolute") {
|
||||
t.Fatalf("expected absolute error: %v", err)
|
||||
}
|
||||
out, err = Read(map[string]any{"path": filepath.Join(d, "missing")})
|
||||
if err != nil || !strings.Contains(out, "STATUS=not_found") {
|
||||
t.Fatalf("missing=%q err=%v", out, err)
|
||||
}
|
||||
}
|
||||
func TestWriteAndEdit(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
p := filepath.Join(d, "x")
|
||||
out, err := Write(map[string]any{"path": p, "content": "a\nb\n"})
|
||||
if err != nil || !strings.Contains(out, "STATE_CHANGED=true") {
|
||||
t.Fatal(out, err)
|
||||
}
|
||||
out, err = Edit(map[string]any{"path": p, "old_string": "a", "new_string": "z"})
|
||||
if err != nil || !strings.Contains(out, "STATE_CHANGED=true") {
|
||||
t.Fatal(out, err)
|
||||
}
|
||||
b, _ := os.ReadFile(p)
|
||||
if string(b) != "z\nb\n" {
|
||||
t.Fatalf("content=%q", b)
|
||||
}
|
||||
_, err = Edit(map[string]any{"path": p, "old_string": "missing", "new_string": "x"})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestGlobAndGrep(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(d, "sub"), 0755)
|
||||
os.WriteFile(filepath.Join(d, "a.go"), []byte("TODO: yes\n"), 0644)
|
||||
os.WriteFile(filepath.Join(d, "sub", "b.go"), []byte("todo: no\n"), 0644)
|
||||
out, err := Glob(map[string]any{"pattern": "**/*.go", "search_dir": d})
|
||||
if err != nil || !strings.Contains(out, "b.go") {
|
||||
t.Fatal(out, err)
|
||||
}
|
||||
out, err = Grep(map[string]any{"pattern": "todo", "path": d, "glob": "**/*.go", "case_insensitive": true})
|
||||
if err != nil || !strings.Contains(out, "a.go") || !strings.Contains(out, "b.go") {
|
||||
t.Fatal(out, err)
|
||||
}
|
||||
_, err = Grep(map[string]any{"pattern": "[", "path": d})
|
||||
if err == nil || !strings.Contains(err.Error(), "regular expression") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package lsp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectAndLanguageID(t *testing.T) {
|
||||
for _, tc := range []struct{ path, adapter, language string }{{"x.go", "gopls", "go"}, {"x.cpp", "clangd", "cpp"}, {"x.php", "intelephense", "php"}, {"x.py", "", "python"}, {"x.unknown", "", "plaintext"}} {
|
||||
a := Detect(tc.path)
|
||||
if tc.adapter == "" {
|
||||
if a != nil {
|
||||
t.Fatalf("Detect(%q)=%v", tc.path, a.Name)
|
||||
}
|
||||
} else if a == nil || a.Name != tc.adapter {
|
||||
t.Fatalf("Detect(%q)=%v", tc.path, a)
|
||||
}
|
||||
if got := LanguageID(tc.path); got != tc.language {
|
||||
t.Errorf("LanguageID(%q)=%q want %q", tc.path, got, tc.language)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestURIs(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "x.go")
|
||||
if got := URIToPath(FileURI(p)); got != p {
|
||||
t.Fatalf("round trip=%q want %q", got, p)
|
||||
}
|
||||
if got := URIToPath("https://example"); got != "https://example" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
func TestFindRoot(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
sub := filepath.Join(d, "a", "b")
|
||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, "go.mod"), []byte("module x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := FindRoot(filepath.Join(sub, "x.go"), []string{"go.mod"}, ""); got != d {
|
||||
t.Fatalf("root=%q want %q", got, d)
|
||||
}
|
||||
if got := FindRoot(filepath.Join(sub, "x.go"), nil, "fallback"); got != "fallback" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompletionKind(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in int
|
||||
want string
|
||||
}{{1, "Text"}, {2, "Method"}, {3, "Function"}, {25, "TypeParameter"}, {0, "Unknown"}, {99, "Unknown"}} {
|
||||
if got := completionKind(tc.in); got != tc.want {
|
||||
t.Errorf("completionKind(%d)=%q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSeverity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in int
|
||||
want string
|
||||
}{{1, "error"}, {2, "warning"}, {3, "info"}, {4, "hint"}, {0, "diagnostic"}, {9, "diagnostic"}} {
|
||||
if got := severity(tc.in); got != tc.want {
|
||||
t.Errorf("severity(%d)=%q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package cmdutil
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolvePosition(t *testing.T) {
|
||||
a := PositionArgs{Path: "src/main.go", Line: "12", Col: "7"}
|
||||
file, line, col := a.ResolvePosition()
|
||||
if abs, _ := filepath.Abs(a.Path); file != abs || line != 11 || col != 6 {
|
||||
t.Fatalf("got %q,%d,%d want %q,11,6", file, line, col, abs)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/ok":
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte("<html><body><h1>Hello</h1></body></html>"))
|
||||
case "/bad":
|
||||
http.Error(w, "no", http.StatusNotFound)
|
||||
case "/text":
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("hello"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
body, err := fetch(srv.URL + "/ok")
|
||||
if err != nil || !strings.Contains(body, "Hello") {
|
||||
t.Fatalf("body=%q err=%v", body, err)
|
||||
}
|
||||
if _, err = fetch(srv.URL + "/bad"); err == nil || !strings.Contains(err.Error(), "HTTP") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = fetch(srv.URL + "/text"); err == nil || !strings.Contains(err.Error(), "not HTML") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestFetchURLValidation(t *testing.T) {
|
||||
for _, u := range []string{"", "ftp://example.com", "/relative"} {
|
||||
if _, err := fetch(u); err == nil || !strings.Contains(err.Error(), "absolute http or https") {
|
||||
t.Errorf("fetch(%q) err=%v", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue