diff --git a/data/tools/file_edit b/data/tools/file_edit deleted file mode 100755 index 9c37ea2..0000000 --- a/data/tools/file_edit +++ /dev/null @@ -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="", old_string="old text", new_string="new text") -# file_edit(path="", old_string="old text", new_string="new text", replace_all="true") -# ``` -# -# **Constraints**: Absolute paths only. Errors if `old_string` matches multiple locations — add surrounding context to disambiguate, or use `replace_all=true`. -# ollie:end - -import sys -import os -import difflib - -sys.path.insert(0, os.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") diff --git a/data/tools/file_glob b/data/tools/file_glob deleted file mode 100755 index 6df1be0..0000000 --- a/data/tools/file_glob +++ /dev/null @@ -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="") -# 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" diff --git a/data/tools/file_grep b/data/tools/file_grep deleted file mode 100755 index 70bb805..0000000 --- a/data/tools/file_grep +++ /dev/null @@ -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="") -# 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)") diff --git a/data/tools/file_read b/data/tools/file_read deleted file mode 100755 index dfc29fb..0000000 --- a/data/tools/file_read +++ /dev/null @@ -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="") -# file_read(path="", start="100", end="200") -# file_read(path="", 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)) diff --git a/data/tools/file_write b/data/tools/file_write deleted file mode 100755 index dd2b642..0000000 --- a/data/tools/file_write +++ /dev/null @@ -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="", 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") diff --git a/justfile b/justfile index c2021a2..beb3300 100644 --- a/justfile +++ b/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 diff --git a/tools/codeintel/internal/codeintel/query_test.go b/tools/codeintel/internal/codeintel/query_test.go index 7ac51ab..43f7a0b 100644 --- a/tools/codeintel/internal/codeintel/query_test.go +++ b/tools/codeintel/internal/codeintel/query_test.go @@ -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) + } +} diff --git a/tools/filetools/cmd/file_edit/main.go b/tools/filetools/cmd/file_edit/main.go new file mode 100644 index 0000000..0641f19 --- /dev/null +++ b/tools/filetools/cmd/file_edit/main.go @@ -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) } diff --git a/tools/filetools/cmd/file_glob/main.go b/tools/filetools/cmd/file_glob/main.go new file mode 100644 index 0000000..8bc46a1 --- /dev/null +++ b/tools/filetools/cmd/file_glob/main.go @@ -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) } diff --git a/tools/filetools/cmd/file_grep/main.go b/tools/filetools/cmd/file_grep/main.go new file mode 100644 index 0000000..e223fad --- /dev/null +++ b/tools/filetools/cmd/file_grep/main.go @@ -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) } diff --git a/tools/filetools/cmd/file_read/main.go b/tools/filetools/cmd/file_read/main.go new file mode 100644 index 0000000..20ca341 --- /dev/null +++ b/tools/filetools/cmd/file_read/main.go @@ -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) } diff --git a/tools/filetools/cmd/file_write/main.go b/tools/filetools/cmd/file_write/main.go new file mode 100644 index 0000000..61cc46e --- /dev/null +++ b/tools/filetools/cmd/file_write/main.go @@ -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) } diff --git a/tools/filetools/internal/filetools/filetools.go b/tools/filetools/internal/filetools/filetools.go new file mode 100644 index 0000000..213ce76 --- /dev/null +++ b/tools/filetools/internal/filetools/filetools.go @@ -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 +} diff --git a/tools/filetools/internal/filetools/filetools_test.go b/tools/filetools/internal/filetools/filetools_test.go new file mode 100644 index 0000000..a32cc3b --- /dev/null +++ b/tools/filetools/internal/filetools/filetools_test.go @@ -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) + } +} diff --git a/tools/lsp/adapter_test.go b/tools/lsp/adapter_test.go new file mode 100644 index 0000000..2e9f5a6 --- /dev/null +++ b/tools/lsp/adapter_test.go @@ -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) + } +} diff --git a/tools/lsp/cmd/lsp_completion/main_test.go b/tools/lsp/cmd/lsp_completion/main_test.go new file mode 100644 index 0000000..05dc15f --- /dev/null +++ b/tools/lsp/cmd/lsp_completion/main_test.go @@ -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) + } + } +} diff --git a/tools/lsp/cmd/lsp_diagnostics/main_test.go b/tools/lsp/cmd/lsp_diagnostics/main_test.go new file mode 100644 index 0000000..869a5f2 --- /dev/null +++ b/tools/lsp/cmd/lsp_diagnostics/main_test.go @@ -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) + } + } +} diff --git a/tools/lsp/cmdutil/cmdutil_test.go b/tools/lsp/cmdutil/cmdutil_test.go new file mode 100644 index 0000000..a9c140a --- /dev/null +++ b/tools/lsp/cmdutil/cmdutil_test.go @@ -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) + } +} diff --git a/tools/web/cmd/web_fetch/main_test.go b/tools/web/cmd/web_fetch/main_test.go new file mode 100644 index 0000000..88fdabd --- /dev/null +++ b/tools/web/cmd/web_fetch/main_test.go @@ -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("

Hello

")) + 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) + } + } +}