replace file tools with Go binaries and add tests

This commit is contained in:
Ollie Agent 2026-08-16 10:39:45 +02:00
parent 929b94e34d
commit 8567ddaa36
19 changed files with 831 additions and 541 deletions

View File

@ -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")

View File

@ -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"

View File

@ -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)")

View File

@ -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))

View File

@ -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")

View File

@ -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

View File

@ -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)
}
}

View File

@ -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) }

View File

@ -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) }

View File

@ -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) }

View File

@ -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) }

View File

@ -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) }

View File

@ -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
}

View File

@ -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)
}
}

48
tools/lsp/adapter_test.go Normal file
View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}