Compare commits

..
40 Commits
Author SHA1 Message Date
lukaszraczylo 62971c9f8c fix(ast_query): skip dependency dirs (node_modules/vendor) by default
ast_query walked every language-matching file under the search root, including
node_modules, vendor and bower_components — parsing third-party code and
flooding results with irrelevant matches (large token cost on every call,
worst for JS/TS node_modules). Skip these by name during the walk, alongside
the existing hidden-dir skip.

The walk root is now exempt from both skips, so targeting a dependency dir
explicitly (paths:["vendor/foo"]) still works — previously naming a hidden or
dependency dir as the root would have skipped everything. Documents the scope
in the tool description and help resource.

Adds a regression test covering default-skip and explicit-target inclusion.
2026-06-20 01:15:37 +01:00
lukaszraczylo 99514ff8d6 fix(parser): extract C++ in-class methods correctly
extractCFunction/extractCFunctionDecl scanned for the first 'identifier' in the declarator, which for C++ methods grabbed a parameter name (e.g. method setName surfaced as 'n') or, when the name was a field_identifier with no parameter identifier, dropped the symbol entirely. cDeclaratorName now descends the declarator field-chain to the real name (identifier/field_identifier/qualified_identifier/destructor/operator); in-class definitions are kinded as methods and qualified Class::method (out-of-line Class::method definitions detected via '::'). Adds TestExtractCppMethods.
2026-06-20 00:54:27 +01:00
lukaszraczylo 2fb9d3e379 fix(search): emit pagination cursor on first truncated page
On the first page (offset==0) ripgrep capped results at exactly max_results
and set Truncated=true, but the cursor-footer guard (len>max_results) was
false, so the response said "(truncated...)" with no cursor — the caller
was told more matches exist but given no way to fetch them. Pagination only
worked from page 2. Emit a cursor on the first page using rg's total match
count for the remaining estimate.

Adds a strict regression test (asserts, not skips); the existing pagination
test was silently skipping its round-trip because page 1 never had a cursor.
2026-06-20 00:44:41 +01:00
lukaszraczylo 3195e353e2 fix(util): rune-safe truncation across formatters
firstLineOf (query) and truncateString (edit) sliced strings at a raw byte
offset, splitting a multibyte UTF-8 character into invalid bytes in
LLM-facing output (ast_query compact format and edit not-found errors are
hot paths). Extract the rune-boundary backoff already used by search's
truncateLine into util.RuneBoundary and route all three sites through it.

Adds table-driven tests for the primitive and a multibyte case for firstLineOf.
2026-06-20 00:44:38 +01:00
lukaszraczylo 549ec2a177 fix(search): truncate match lines on a rune boundary
truncateLine byte-sliced s[:maxLen-3], which can split a multibyte UTF-8 character and emit an invalid sequence (mojibake) on long non-ASCII match lines. Back off to the nearest rune boundary via utf8.RuneStart. Adds TestTruncateLineRuneSafe.
2026-06-20 00:33:34 +01:00
lukaszraczylo 96db1c7749 fix(config): clamp max_edit_size and max_search_results
Only max_file_size and max_parse_size were clamped after reading .mcp-filepuff.json; max_edit_size and max_search_results were unbounded, letting a config file request excessive memory. Clamp max_edit_size to 100MB and max_search_results to 100k (and floor negatives to 0). Adds TestLoadClampsExcessiveLimits.
2026-06-20 00:33:34 +01:00
lukaszraczylo 5452cfce30 fix(parser): deterministic order for remaining doc-comment tags
FormatDocComment emitted leftover tags by ranging a map, so multi-tag comments (@example/@see/@deprecated) rendered in random order run-to-run — nondeterministic output that defeats caching/diffing. Sort the remaining tag names before emitting.
2026-06-20 00:33:34 +01:00
lukaszraczylo 85a539b18b fix(parser): exclude function-local var/const from symbols
extractGoSymbols/extractJSSymbols matched var/const/let at any tree depth, so every function-local var, const (Go) and let/const/var (JS/TS) was emitted as a top-level symbol — flooding include_ast/ast_query symbol lists with locals (inaccurate + token-wasteful). Skip declarations nested inside a function/block body (isNestedInBody); module-level and 'export const' declarations are still kept. Adds TestExtractSymbolsExcludesLocals.
2026-06-20 00:33:33 +01:00
lukaszraczylo 084c1fd2b2 test(edit): cover CRLF line-ending preservation on apply
Locks the documented guarantee that inserting LF content into a CRLF file normalizes to CRLF, with no bare-LF leaks. Closes a test gap on a stated design goal.
2026-06-20 00:33:33 +01:00
lukaszraczylo 2e444401f5 fix(parser): name Elixir structs after their enclosing module
extractElixirStruct hardcoded Name="defstruct", so every Elixir struct surfaced as a symbol literally called "defstruct" — useless for navigation and colliding across modules. Walk ancestors to the enclosing defmodule and name the struct %ModuleName{}. Adds TestExtractElixirStructNamedByModule.
2026-06-20 00:33:33 +01:00
lukaszraczylo 1b41e43bf3 feat(lsp): capture server stderr to diagnose init failures
drainStderr discarded all server stderr, so an LSP init failure (e.g. 'command not found', missing module) surfaced as an opaque 'initialization failed'. Retain a bounded tail (StderrCaptureMax=4096B, oldest dropped) under a mutex and attach it to the init-failure error context. Adds TestClientStderrCapture.
2026-06-20 00:33:33 +01:00
lukaszraczylo 13b1a22102 perf(parser): denser symbols-only skeleton fallback
The fallback skeleton (c/cpp/elixir/html/vue) is not valid source, so the '// ' comment framing and '(line N)' wording just cost tokens. Use a compact 'kind name :line' form. ~15-25% fewer tokens on fallback skeletons; output stays accurate.
2026-06-20 00:33:33 +01:00
lukaszraczylo d0f649a9b8 docs(cursor): justify the ignored json.Marshal error in Encode
Per the project's error-handling rule, an ignored error needs a comment. Marshalling the two-scalar payload struct cannot fail; document why the error is dropped. No behavior change.
2026-06-20 00:33:33 +01:00
lukaszraczylo 5214ad2b66 fix(parser): keep signature in decorated Python skeleton
skeletonPythonNode took lines[0] of a decorated_definition, which is the first @decorator line, dropping the def/class signature entirely. Now emit the decorator line(s) then recurse into the wrapped definition so its signature is preserved. Adds skeleton_test.go (decorated + plain function).
2026-06-20 00:33:33 +01:00
lukaszraczylo 80c3f03f62 fix(lsp): non-blocking response dispatch to avoid client deadlock
readLoop sent the response on the pending channel while holding c.mu. A misbehaving server sending a duplicate response for the same id would block on the buffered(1) channel under the lock, deadlocking all future dispatches. Use a non-blocking send (matching the notification path).
2026-06-20 00:33:33 +01:00
lukaszraczylo 61b04b46cd fix(metrics): ignore negative Counter.Add to keep counters monotonic
Counter.Add applied any delta, so a negative value would decrement a counter that must be monotonic, corrupting rate()-style downstream math. Ignore delta<0. Adds TestCounterRejectsNegativeAdd.
2026-06-20 00:33:32 +01:00
lukaszraczylo debe79e33e feat(search): bound default result count and add include_hidden
Two file_search gaps:
- max_results defaulted to 0 (unbounded), so a broad pattern returned every match — unbounded memory + token blowup. The configured MaxSearchResults (default 1000) existed but was dead; wire it as the builtIn default. Explicit max_results still wins, and the cursor footer pages the rest.
- search.Request.IncludeHidden was a dead field with no tool param. Add an include_hidden bool (default false) so dotfiles (.github/, etc.) are searchable.
2026-06-20 00:33:32 +01:00
lukaszraczylo dfd68f08c9 feat(ast_query): default to compact format for token efficiency
ast_query defaulted to verbose output (full code + up to 500 chars/match + captures), which is very token-heavy on multi-match queries. Default to compact (one line per match: file:line (kind) firstline) — roughly 5-10x fewer tokens on typical queries — while verbose remains available per-call (format=verbose) or via the default_format session pref. Updates tool description, help resource, and TestASTQueryFormatCompactDefault.
2026-06-20 00:33:32 +01:00
lukaszraczylo b2c4101b5b fix(edit): correct diff @@ hunk headers at file boundaries
hunkBounds approximated one-sided start lines by reusing the opposite side's number, and re-assigned it every iteration inside the count==0 guard. For pure insertion/deletion hunks (e.g. writing into an empty file, or deleting a whole file) this produced wrong headers like '@@ -2,0 +1,2 @@' instead of '@@ -0,0 +1,2 @@'. flattenDiffLines now records both running line numbers on every line, so hunkBounds takes the start from the first line directly and applies GNU's zero-count convention (line-before-change, clamped to 0). Adds TestGenerateDiffHunkHeaderBounds.
2026-06-20 00:33:32 +01:00
lukaszraczylo 53bc06df7f refactor(ast_query): emit cursor footer from formatter, not string-replace
handleASTQuery rebuilt the formatter's '[remaining: N]' placeholder string and strings.ReplaceAll'd it with the cursor line, plus a fallback append — fragile coupling to the formatter's exact output. New FormatResultsWithCursor appends the prebuilt '[cursor: ..., remaining: N]' footer in place of the bare hint when results are truncated; the handler just passes it through. FormatResultsWithOptions is unchanged (delegates with empty cursor). Adds TestFormatResultsWithCursor.
2026-06-20 00:33:32 +01:00
lukaszraczylo cc1661d295 feat(lsp): query by symbol_name; document ast_query match limits
Two ast_query/lsp_query ergonomics improvements that share the tool registration and help files:

- lsp_query accepts symbol_name (+symbol_kind) and resolves the position via AST (resolveLSPSymbolPosition), so callers need not know line:col; line/column are no longer required. SymbolCandidate gains NameLine/NameColumn for the precise identifier position. Ambiguous/unknown names error with candidates / a 'did you mean' hint. Adds TestResolveLSPSymbolPosition.
- ast_query help + tool description now document that matching is keyword/kind-based, not structural: pattern arg/return constraints are not enforced; use name_exact/name_matches/kind_in to filter. Fixes a misleading 'functions returning error' example.
2026-06-20 00:33:32 +01:00
lukaszraczylo b6489ee4bc docs(lsp): clarify drainStderr cancellation relies on pipe EOF
The goroutine parks in the blocking stderr.Read, so closing c.done does not interrupt it; termination relies on the read erroring at pipe EOF when the process is killed. The c.done select is only a best-effort fast path.
2026-06-20 00:33:32 +01:00
lukaszraczylo 5ed14457f1 fix(parser): stop fabricating { ... } in fallback skeleton
renderSymbolsOnly (fallback for c/cpp/elixir/html/vue) appended each symbol's raw start line plus a literal ' { ... }'. For brace-less languages (Elixir 'def f do', HTML) that is syntactically wrong and wastes tokens. Emit only the accurate symbol list; drop the fabricated line and unused params.
2026-06-20 00:33:32 +01:00
lukaszraczylo 1c26f8d105 feat(read): suggest nearest symbols when symbol_name not found
A misspelled or stale symbol_name returned a bare 'not found' error, forcing blind retries. nearestSymbolSuggestion fuzzy-matches the requested name against the file's actual symbols (via existing pkg/fuzzy) and appends 'did you mean: a, b, c?'. The re-parse is served from the parser cache. Adds TestReadSymbolNotFoundSuggestsNearest.
2026-06-20 00:33:32 +01:00
lukaszraczylo 7b8914017c fix(search): drop redundant truncation prose when cursor present
When a pagination cursor footer is emitted it already reports the remaining count; the separate '(truncated, showing subset of N)' prose line duplicated that signal. Suppress the prose line when CursorLine is set; keep it for non-paginated truncation.
2026-06-20 00:33:31 +01:00
lukaszraczylo 5827290ece fix(edit): point empty-replace error at operation=delete
A replace with empty new_content failed with a bare 'new_content is required' message, leaving the model to guess. Append a hint to use operation=delete when the intent is removal. Adds TestValidateReplaceEmptySuggestsDelete.
2026-06-20 00:33:31 +01:00
lukaszraczylo b0c1fc0480 fix(parser): extract all names from grouped var/const specs
extractGoVarConst used ChildByFieldName("name"), which returns only the first name field, so 'var a, b = 1, 2' and 'const ( C, D = 3, 4 )' surfaced only a/C. Iterate every "name"-field child so each declared identifier becomes its own symbol. Adds TestExtractGoVarConstMultiName.
2026-06-20 00:33:31 +01:00
lukaszraczylo a0b41a6eb8 fix(parser): improve strip accuracy for imports and license
Three strip=imports/license correctness fixes (all heuristic, keyword/structure-gated):
- stripTSImports now tracks multi-line named imports (import {\n ... \n} from 'x') instead of dropping only the first line and leaving dangling members; side-effect imports without semicolons no longer trigger runaway consumption.
- stripGoImports recognizes all single-import spec forms: import _ "x", import . "x", and aliased import m "x" (previously only import "x"/backtick).
- stripLicense peels a leading shebang (#!) or Go build-constraint (//go:build, // +build) block and preserves it above the license, and now strips // line-comment license headers (not just /* */ and #). Adds tests for each.
2026-06-20 00:33:31 +01:00
lukaszraczylo c261fb8c76 fix(parser): detect ambiguous symbol_name reads
FindSymbolRange returned the first AST node matching a name, so file_read with symbol_name silently read the wrong symbol when a name collided (e.g. two String() methods on different receivers, or a func and type sharing a name). New FindSymbolCandidates returns all matches; the file_read handler now errors on ambiguity, listing each candidate's node type and line range so the caller can disambiguate via symbol_kind or line_start/line_end. FindSymbolRange is kept (first match) for callers that don't need ambiguity detection. Adds TestFindSymbolCandidates.
2026-06-20 00:33:31 +01:00
lukaszraczylo ff37be1f17 fix(lsp): parse LocationLink in definition results
We advertise textDocument.definition.linkSupport=true, so gopls and rust-analyzer return the LocationLink shape (targetUri/targetRange). The old code only tried []Location then a single Location; unmarshaling LocationLink JSON into []Location silently succeeds with zero-value URIs/ranges, producing garbage locations (or a false 'No definition found'). parseDefinitionResult now decodes a unified shape capturing both field sets and normalizes to Location, and surfaces a real parse error instead of swallowing it. Adds TestParseDefinitionResult covering all shapes.
2026-06-20 00:33:31 +01:00
lukaszraczylo 36deb948c8 feat(lsp): always signal AST fallback when LSP unavailable
lspHover fell back to AST symbol info on any LSP error, but the '(AST fallback)' marker only showed when verbose=true. In the default mode the model received Node type/Text with no signal the answer was a degraded AST guess. Now always emit '[AST fallback — LSP unavailable]' so the model can decide whether to retry. Includes colocated QF1012 style fixups.
2026-06-20 00:33:31 +01:00
lukaszraczylo 833e898d20 fix(server): skip oversized files in AST walk, fix line count
Two AST-summary/query path fixes plus colocated QF1012 style fixups:
- runASTQueryWalk now checks info.Size() > MaxFileSize before os.ReadFile, avoiding a full read of files it will discard.
- generateASTSummary used len(splitLines(...)) for the '(N lines)' header, over-counting newline-terminated files by one (phantom trailing element). New countLines() helper counts via bytes.Count. Adds TestCountLines.
2026-06-20 00:33:31 +01:00
lukaszraczylo 02d54b56bb perf(parser): cache syntax errors to skip walk on cache hit
extractErrors is a pure function of the parsed tree (it ignores content), so re-walking the whole tree on every cache hit wasted work on the hottest path (file_read+include_ast, ast_query, edit validation). Store []SyntaxError in CachedTree and return it directly on hits. Adds TestParseCachedErrorsMatch asserting cache-hit errors equal the cache-miss parse.
2026-06-20 00:33:31 +01:00
lukaszraczylo e11f140997 style: use fmt.Fprintf instead of WriteString(fmt.Sprintf) (staticcheck QF1012)
Mechanical fix applied by golangci-lint --fix. Pre-existing debt that fails the staticcheck QF1012 quickfix check; no behavior change. Clears the lint gate so subsequent commits in this package set pass.
2026-06-20 00:33:30 +01:00
lukaszraczyloandGitHub aa7d884b0c Update go.mod and go.sum (#30) 2026-06-17 05:32:41 +01:00
lukaszraczyloandGitHub 0fe678cd60 Update go.mod and go.sum (#29) 2026-06-09 05:26:55 +01:00
lukaszraczyloandGitHub 70484d20c3 Update go.mod and go.sum (#28) 2026-05-29 05:31:00 +01:00
lukaszraczylo 6e0c5247c1 ci: replace retired macos-13 runner with macos-15-intel for darwin_amd64
macos-13 Intel runner image retired by GitHub on 2025-12-08; darwin_amd64
build job queued indefinitely with no available runner. Switch to the
standard macos-15-intel label (native x86_64, free for public repos,
supported until Aug 2027). CGO required by go-tree-sitter, so a native
Intel runner is needed.
2026-05-29 01:43:22 +01:00
lukaszraczylo 7774dc29d1 fix(release): partition CGO split builds by target to stop cross-arch failure
GoReleaser Pro --split defaults to partitioning by GOOS only, so each
linux matrix runner built both linux/amd64 and linux/arm64. With
CGO_ENABLED=1 (go-tree-sitter), the native gcc cannot assemble the other
arch's CGO runtime (gcc_arm64.S: no such instruction), failing the build.

- .goreleaser.yaml: set partial.by=target (partition by GOOS+GOARCH)
- workflow-prepare.sh: export GGOOS/GGOARCH from the matrix TARGET_* vars
  so each native runner builds exactly its own target

GGOOS/GGOARCH are filter-only and do not bleed into before-hooks, unlike
GOOS/GOARCH.
2026-05-29 00:57:29 +01:00
lukaszraczylo 9af2801b1b refactor(edit): remove auto-indentation and add line-ending normalization
- [x] Remove auto-indentation from text mode edits (caller controls whitespace)
- [x] Add line-ending detection and normalization for both AST and text modes
- [x] Share edit logic via new `spliceContent` function for both modes
- [x] Fix diff to emit "No newline at end of file" markers
- [x] Fix diff to strip raw CR from CRLF file output
- [x] Remove double-unescape of backslash sequences in new_content
- [x] Fix countDiffLines to be hunk-aware (correctly count lines starting with +/-)
- [x] Fix block-comment stripping to remove standalone lines cleanly
- [x] Fix Python license header stripping to preserve separator blank lines
2026-05-29 00:17:36 +01:00
48 changed files with 2361 additions and 602 deletions
+1
View File
@@ -22,6 +22,7 @@ jobs:
rolling-release-tag: "v1" rolling-release-tag: "v1"
platforms: >- platforms: >-
[ [
{"os":"macos-15-intel","goos":"darwin","goarch":"amd64","platform":"darwin_amd64"},
{"os":"macos-latest","goos":"darwin","goarch":"arm64","platform":"darwin_arm64"}, {"os":"macos-latest","goos":"darwin","goarch":"arm64","platform":"darwin_arm64"},
{"os":"ubuntu-latest","goos":"linux","goarch":"amd64","platform":"linux_amd64"}, {"os":"ubuntu-latest","goos":"linux","goarch":"amd64","platform":"linux_amd64"},
{"os":"ubuntu-24.04-arm","goos":"linux","goarch":"arm64","platform":"linux_arm64"}, {"os":"ubuntu-24.04-arm","goos":"linux","goarch":"arm64","platform":"linux_arm64"},
+14 -8
View File
@@ -5,9 +5,12 @@ version: 2
project_name: mcp-filepuff project_name: mcp-filepuff
# Split mode by full target (goos+goarch). Each matrix runner only builds the # Split CGO builds by full target (GOOS+GOARCH), not just GOOS.
# artifact matching its own architecture, avoiding CGO cross-compile failures # Each release matrix runner builds natively for exactly one target via the
# (ARM asm on x86 runners and vice versa). # GGOOS/GGOARCH filters set in workflow-prepare.sh. Without `by: target` the
# split partitions by GOOS only, so a single linux runner would try to build
# both amd64 and arm64 — and native gcc cannot assemble the other arch's CGO
# runtime (gcc_arm64.S errors), breaking the release.
partial: partial:
by: target by: target
@@ -37,22 +40,25 @@ builds:
- amd64 - amd64
- arm64 - arm64
ignore: ignore:
- goos: darwin
goarch: amd64
- goos: windows - goos: windows
goarch: arm64 goarch: arm64
archives: archives:
- id: default - id: default
# Ship raw binaries — downloaders curl the asset and run it directly
# (Windows gets .exe appended automatically). No tarball indirection.
formats: formats:
- binary - tar.gz
name_template: >- name_template: >-
{{ .ProjectName }}_ {{ .ProjectName }}_
{{- .Version }}_ {{- .Version }}_
{{- .Os }}_ {{- .Os }}_
{{- .Arch }} {{- .Arch }}
files:
- LICENSE
- README.md
format_overrides:
- goos: windows
formats:
- zip
checksum: checksum:
name_template: 'checksums.txt' name_template: 'checksums.txt'
+13 -9
View File
@@ -58,21 +58,25 @@ Claude Code configuration (`.claude/settings.json`):
Download pre-built binaries from the [releases page](https://github.com/lukaszraczylo/filepuff-mcp/releases): Download pre-built binaries from the [releases page](https://github.com/lukaszraczylo/filepuff-mcp/releases):
```bash ```bash
# macOS (Apple Silicon) # macOS (ARM64)
curl -fsSL -o mcp-filepuff https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_arm64 curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_arm64.tar.gz | tar xz
chmod +x mcp-filepuff && mv mcp-filepuff ~/.local/bin/ mv mcp-filepuff ~/.local/bin/
# macOS (AMD64)
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_amd64.tar.gz | tar xz
mv mcp-filepuff ~/.local/bin/
# Linux (ARM64) # Linux (ARM64)
curl -fsSL -o mcp-filepuff https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_arm64 curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_arm64.tar.gz | tar xz
chmod +x mcp-filepuff && mv mcp-filepuff ~/.local/bin/ mv mcp-filepuff ~/.local/bin/
# Linux (AMD64) # Linux (AMD64)
curl -fsSL -o mcp-filepuff https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_amd64 curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_amd64.tar.gz | tar xz
chmod +x mcp-filepuff && mv mcp-filepuff ~/.local/bin/ mv mcp-filepuff ~/.local/bin/
# Windows (PowerShell) # Windows (PowerShell)
Invoke-WebRequest -Uri "https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_windows_amd64.exe" -OutFile mcp-filepuff.exe Invoke-WebRequest -Uri "https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_windows_amd64.zip" -OutFile mcp-filepuff.zip
Move-Item mcp-filepuff.exe $env:USERPROFILE\.local\bin\ Expand-Archive mcp-filepuff.zip -DestinationPath $env:USERPROFILE\.local\bin
``` ```
Replace `<version>` with the actual version (e.g., `v1.0.0`). Replace `<version>` with the actual version (e.g., `v1.0.0`).
+9 -9
View File
@@ -211,7 +211,7 @@ func GenerateMarkdown() string {
var sb strings.Builder var sb strings.Builder
sb.WriteString("# MCP Filepuff API Reference\n\n") sb.WriteString("# MCP Filepuff API Reference\n\n")
sb.WriteString(fmt.Sprintf("> Auto-generated on %s\n\n", time.Now().Format("2006-01-02"))) fmt.Fprintf(&sb, "> Auto-generated on %s\n\n", time.Now().Format("2006-01-02"))
sb.WriteString("This document provides detailed API documentation for all MCP tools available in filepuff.\n\n") sb.WriteString("This document provides detailed API documentation for all MCP tools available in filepuff.\n\n")
// Table of Contents // Table of Contents
@@ -228,9 +228,9 @@ func GenerateMarkdown() string {
} }
for _, cat := range categoryOrder { for _, cat := range categoryOrder {
sb.WriteString(fmt.Sprintf("### %s\n", cat)) fmt.Fprintf(&sb, "### %s\n", cat)
for _, tool := range categories[cat] { for _, tool := range categories[cat] {
sb.WriteString(fmt.Sprintf("- [`%s`](#%s)\n", tool.Name, tool.Name)) fmt.Fprintf(&sb, "- [`%s`](#%s)\n", tool.Name, tool.Name)
} }
sb.WriteString("\n") sb.WriteString("\n")
} }
@@ -240,11 +240,11 @@ func GenerateMarkdown() string {
sb.WriteString("## Tool Reference\n\n") sb.WriteString("## Tool Reference\n\n")
for _, cat := range categoryOrder { for _, cat := range categoryOrder {
sb.WriteString(fmt.Sprintf("### %s\n\n", cat)) fmt.Fprintf(&sb, "### %s\n\n", cat)
for _, tool := range categories[cat] { for _, tool := range categories[cat] {
sb.WriteString(fmt.Sprintf("#### `%s`\n\n", tool.Name)) fmt.Fprintf(&sb, "#### `%s`\n\n", tool.Name)
sb.WriteString(fmt.Sprintf("%s\n\n", tool.Description)) fmt.Fprintf(&sb, "%s\n\n", tool.Description)
// Read-only indicator // Read-only indicator
if tool.ReadOnly { if tool.ReadOnly {
@@ -263,8 +263,8 @@ func GenerateMarkdown() string {
if param.Required { if param.Required {
required = "**Yes**" required = "**Yes**"
} }
sb.WriteString(fmt.Sprintf("| `%s` | `%s` | %s | %s |\n", fmt.Fprintf(&sb, "| `%s` | `%s` | %s | %s |\n",
param.Name, param.Type, required, param.Description)) param.Name, param.Type, required, param.Description)
} }
sb.WriteString("\n") sb.WriteString("\n")
} else { } else {
@@ -285,7 +285,7 @@ func GenerateMarkdown() string {
if len(tool.Notes) > 0 { if len(tool.Notes) > 0 {
sb.WriteString("**Notes:**\n\n") sb.WriteString("**Notes:**\n\n")
for _, note := range tool.Notes { for _, note := range tool.Notes {
sb.WriteString(fmt.Sprintf("- %s\n", note)) fmt.Fprintf(&sb, "- %s\n", note)
} }
sb.WriteString("\n") sb.WriteString("\n")
} }
+3 -1
View File
@@ -6,7 +6,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 github.com/cespare/xxhash/v2 v2.3.0
github.com/goccy/go-json v0.10.6 github.com/goccy/go-json v0.10.6
github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mark3labs/mcp-go v0.49.0 github.com/mark3labs/mcp-go v0.55.0
github.com/sergi/go-diff v1.4.0 github.com/sergi/go-diff v1.4.0
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
@@ -15,6 +15,8 @@ require (
require ( require (
github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/text v0.38.0 // indirect
) )
+14 -7
View File
@@ -3,6 +3,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
@@ -22,12 +24,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mark3labs/mcp-go v0.49.0 h1:7Ssx4d7/T86qnWoJIdye7wEEvUzv39UIbnZb/FqUZMY= github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
github.com/mark3labs/mcp-go v0.49.0/go.mod h1:BflTAZAzXlrTpiO44gmjMu89n2FO56rJ9m31fp4zd5k= github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4= github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
@@ -36,13 +40,16 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+11 -1
View File
@@ -84,14 +84,24 @@ func Load(workspaceRoot string) (*Config, error) {
// Restore WorkspaceRoot — config file must not override path guards. // Restore WorkspaceRoot — config file must not override path guards.
cfg.WorkspaceRoot = savedRoot cfg.WorkspaceRoot = savedRoot
// Clamp size limits to prevent config file from requesting excessive memory. // Clamp size/count limits so the config file cannot request excessive memory.
const maxAllowedSize int64 = 100 * 1024 * 1024 // 100 MB const maxAllowedSize int64 = 100 * 1024 * 1024 // 100 MB
const maxAllowedSearchResults = 100000
if cfg.MaxFileSize > maxAllowedSize { if cfg.MaxFileSize > maxAllowedSize {
cfg.MaxFileSize = maxAllowedSize cfg.MaxFileSize = maxAllowedSize
} }
if cfg.MaxParseSize > maxAllowedSize { if cfg.MaxParseSize > maxAllowedSize {
cfg.MaxParseSize = maxAllowedSize cfg.MaxParseSize = maxAllowedSize
} }
if cfg.MaxEditSize > maxAllowedSize {
cfg.MaxEditSize = maxAllowedSize
}
if cfg.MaxSearchResults > maxAllowedSearchResults {
cfg.MaxSearchResults = maxAllowedSearchResults
}
if cfg.MaxSearchResults < 0 {
cfg.MaxSearchResults = 0 // negative would re-enable unbounded search
}
// Override from environment variables // Override from environment variables
cfg.loadFromEnv() cfg.loadFromEnv()
+29
View File
@@ -183,6 +183,35 @@ func TestLoadWithConfigFile(t *testing.T) {
} }
} }
// A config file requesting excessive size/count limits must be clamped so it
// cannot force the server into unbounded memory use.
func TestLoadClampsExcessiveLimits(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
configContent := `{
"max_file_size": 999999999999,
"max_parse_size": 999999999999,
"max_edit_size": 999999999999,
"max_search_results": 999999999
}`
if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
const maxSize int64 = 100 * 1024 * 1024
if cfg.MaxFileSize > maxSize || cfg.MaxParseSize > maxSize || cfg.MaxEditSize > maxSize {
t.Errorf("size limits not clamped: file=%d parse=%d edit=%d", cfg.MaxFileSize, cfg.MaxParseSize, cfg.MaxEditSize)
}
if cfg.MaxSearchResults > 100000 {
t.Errorf("MaxSearchResults not clamped: %d", cfg.MaxSearchResults)
}
}
// TestValidate tests the Validate method with various inputs. // TestValidate tests the Validate method with various inputs.
func TestValidate(t *testing.T) { func TestValidate(t *testing.T) {
tests := []struct { tests := []struct {
+2
View File
@@ -26,6 +26,8 @@ type payload struct {
// Encode creates an opaque cursor string from an offset and query hash. // Encode creates an opaque cursor string from an offset and query hash.
func Encode(offset int, queryHash string) string { func Encode(offset int, queryHash string) string {
p := payload{Offset: offset, QueryHash: queryHash} p := payload{Offset: offset, QueryHash: queryHash}
// Marshalling a struct of two scalar fields (int, string) cannot fail, so the
// error is intentionally ignored.
b, _ := json.Marshal(p) b, _ := json.Marshal(p)
return base64.RawURLEncoding.EncodeToString(b) return base64.RawURLEncoding.EncodeToString(b)
} }
+188 -213
View File
@@ -270,7 +270,11 @@ func (e *Engine) validateBaseEdit(edit *ASTEdit) error {
switch edit.Operation { switch edit.Operation {
case EditReplace, EditInsertBefore, EditInsertAfter: case EditReplace, EditInsertBefore, EditInsertAfter:
if edit.NewContent == "" { if edit.NewContent == "" {
return errors.NewInvalidEditError(fmt.Sprintf("new_content is required for %s operation", edit.Operation)) msg := fmt.Sprintf("new_content is required for %s operation", edit.Operation)
if edit.Operation == EditReplace {
msg += " (to remove content instead, use operation=delete)"
}
return errors.NewInvalidEditError(msg)
} }
case EditDelete: case EditDelete:
// new_content not required // new_content not required
@@ -481,98 +485,104 @@ func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte
return true return true
} }
// applyEdit applies the edit operation to the content. // applyEdit applies an AST-mode edit. new_content is inserted verbatim — the caller
// AST mode uses exact byte positions — new_content is inserted verbatim without auto-indentation. // controls indentation — and only line endings are normalized to the file's convention.
func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]byte, error) { func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]byte, error) {
startByte := node.StartByte() eol := detectLineEnding(content)
endByte := node.EndByte() newContent := normalizeLineEndings(edit.NewContent, eol)
return spliceContent(edit.Operation, content, int(node.StartByte()), int(node.EndByte()), newContent, eol)
}
newContent := edit.NewContent // detectLineEnding reports the file's dominant line-ending convention: "\r\n" when any
// CRLF terminator is present, otherwise "\n".
func detectLineEnding(content []byte) string {
if bytes.Contains(content, []byte("\r\n")) {
return "\r\n"
}
return "\n"
}
// normalizeLineEndings rewrites every line ending in s to eol. It first collapses CRLF to
// LF, then expands to the target, so mixed input becomes uniform and new_content can never
// introduce a line ending foreign to the file being edited.
func normalizeLineEndings(s, eol string) string {
if s == "" {
return s
}
s = strings.ReplaceAll(s, "\r\n", "\n")
if eol != "\n" {
s = strings.ReplaceAll(s, "\n", eol)
}
return s
}
func endsWithNewline(s string) bool { return strings.HasSuffix(s, "\n") }
func startsWithNewline(s string) bool {
return s != "" && (s[0] == '\n' || s[0] == '\r')
}
// spliceContent applies an edit operation by splicing newContent into content over the
// byte range [start, end). It is shared by AST and text modes — once auto-indentation is
// removed the two are identical. Restored terminators and separators use eol so the
// file's line-ending convention is preserved.
func spliceContent(op EditOperation, content []byte, start, end int, newContent, eol string) ([]byte, error) {
// A line-based selection on a CRLF file can land `end` between the \r (treated as
// line content) and the \n of a terminator. Pull it back so the full \r\n stays
// intact in the tail and is never split into a bare LF.
if end > start && end < len(content) && content[end-1] == '\r' && content[end] == '\n' {
end--
}
var result []byte var result []byte
switch op {
switch edit.Operation {
case EditReplace: case EditReplace:
result = append(result, content[:startByte]...) result = append(result, content[:start]...)
result = append(result, []byte(newContent)...) result = append(result, newContent...)
// Preserve trailing newline: if selection ended with \n but replacement doesn't, // Restore a line terminator if the replaced range ended with one but the
// re-add it to prevent line merging // replacement does not, to prevent merging with the following line.
if endByte > startByte && content[endByte-1] == '\n' && !strings.HasSuffix(newContent, "\n") { if end > start && content[end-1] == '\n' && !endsWithNewline(newContent) {
result = append(result, '\n') result = append(result, eol...)
} }
result = append(result, content[endByte:]...) result = append(result, content[end:]...)
case EditInsertBefore: case EditInsertBefore:
insertion := newContent insertion := newContent
if !strings.HasSuffix(insertion, "\n") { if !endsWithNewline(insertion) {
insertion += "\n" insertion += eol
} }
result = append(result, content[:startByte]...) result = append(result, content[:start]...)
result = append(result, []byte(insertion)...) result = append(result, insertion...)
result = append(result, content[startByte:]...) result = append(result, content[start:]...)
case EditInsertAfter: case EditInsertAfter:
insertion := newContent insertion := newContent
// Ensure separation from preceding content // Separate from preceding content.
if endByte > 0 && content[endByte-1] != '\n' && !strings.HasPrefix(insertion, "\n") { if end > 0 && content[end-1] != '\n' && !startsWithNewline(insertion) {
insertion = "\n" + insertion insertion = eol + insertion
} }
// Ensure separation from following content // Separate from following content.
if !strings.HasSuffix(insertion, "\n") && endByte < uint32(len(content)) && content[endByte] != '\n' { if !endsWithNewline(insertion) && end < len(content) && content[end] != '\n' {
insertion += "\n" insertion += eol
} }
result = append(result, content[:endByte]...) result = append(result, content[:end]...)
result = append(result, []byte(insertion)...) result = append(result, insertion...)
result = append(result, content[endByte:]...) result = append(result, content[end:]...)
case EditDelete: case EditDelete:
result = append(result, content[:startByte]...) result = append(result, content[:start]...)
result = append(result, content[endByte:]...) result = append(result, content[end:]...)
default: default:
return nil, errors.NewInvalidEditError(fmt.Sprintf("unknown operation: %s", edit.Operation)) return nil, errors.NewInvalidEditError(fmt.Sprintf("unknown operation: %s", op))
} }
return result, nil return result, nil
} }
// detectIndentation detects the indentation at a given byte position. // noNewlineMarker is the git-style annotation emitted after a diff line whose source
func detectIndentation(content []byte, bytePos int) string { // version has no trailing newline.
// Find the start of the line const noNewlineMarker = "\\ No newline at end of file\n"
lineStart := bytePos
for lineStart > 0 && content[lineStart-1] != '\n' {
lineStart--
}
// Extract leading whitespace
var indent strings.Builder
for i := lineStart; i < bytePos && i < len(content); i++ {
c := content[i]
if c == ' ' || c == '\t' {
indent.WriteByte(c)
} else {
break
}
}
return indent.String()
}
// indentContent applies indentation to multi-line content.
func indentContent(content string, indent string) string {
if indent == "" {
return content
}
lines := strings.Split(content, "\n")
for i, line := range lines {
if i > 0 && line != "" {
lines[i] = indent + line
}
}
return strings.Join(lines, "\n")
}
// diffLine represents a single line in the diff with its type and content. // diffLine represents a single line in the diff with its type and content.
type diffLine struct { type diffLine struct {
@@ -582,137 +592,148 @@ type diffLine struct {
newN int // 1-based line number in modified (0 if delete) newN int // 1-based line number in modified (0 if delete)
} }
// indexRange is an inclusive [start, end] range of diffLine indices forming one hunk.
type indexRange struct{ start, end int }
// generateDiff creates a unified diff between original and modified content. // generateDiff creates a unified diff between original and modified content.
// Uses line-level Myers diff algorithm and outputs a proper unified diff // Uses a line-level Myers diff and outputs a unified diff with 3 lines of context
// with context lines (3 before/after each change, merging close hunks). // before/after each change, merging close hunks.
func (e *Engine) generateDiff(original, modified, filename string) string { func (e *Engine) generateDiff(original, modified, filename string) string {
dmp := e.dmp dmp := e.dmp
// Use line-level diffing: encode each line as a single character, // Line-level diffing: encode each line as a single rune, diff the encoded strings,
// diff the encoded strings, then decode back to real lines. // then decode back to real lines.
chars1, chars2, lineArray := dmp.DiffLinesToChars(original, modified) chars1, chars2, lineArray := dmp.DiffLinesToChars(original, modified)
diffs := dmp.DiffMain(chars1, chars2, false) diffs := dmp.DiffCharsToLines(dmp.DiffMain(chars1, chars2, false), lineArray)
diffs = dmp.DiffCharsToLines(diffs, lineArray)
// Cleanup for readability
diffs = dmp.DiffCleanupSemantic(diffs) diffs = dmp.DiffCleanupSemantic(diffs)
// Flatten diffs into individual lines with line numbers // Track whether each version lacks a final newline, so the diff is annotated
var lines []diffLine // git-style ("\ No newline at end of file") instead of implying a phantom one.
oldLine := 1 origNoEOL := len(original) > 0 && !strings.HasSuffix(original, "\n")
newLine := 1 modNoEOL := len(modified) > 0 && !strings.HasSuffix(modified, "\n")
lines, maxOldN, maxNewN := flattenDiffLines(diffs)
ranges := diffHunkRanges(lines)
if len(ranges) == 0 {
return "" // no changes
}
var buf bytes.Buffer
fmt.Fprintf(&buf, "--- %s\n", filename)
fmt.Fprintf(&buf, "+++ %s\n", filename)
for _, r := range ranges {
oldStart, oldCount, newStart, newCount := hunkBounds(lines, r.start, r.end)
fmt.Fprintf(&buf, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)
writeDiffBody(&buf, lines, r.start, r.end, origNoEOL, modNoEOL, maxOldN, maxNewN)
}
return buf.String()
}
// flattenDiffLines expands diff segments into per-line records with 1-based line numbers,
// returning the lines plus the final line number of each version (for no-newline marking).
func flattenDiffLines(diffs []diffmatchpatch.Diff) (lines []diffLine, maxOldN, maxNewN int) {
oldLine, newLine := 1, 1
for _, d := range diffs { for _, d := range diffs {
rawLines := strings.SplitAfter(d.Text, "\n") for _, raw := range strings.SplitAfter(d.Text, "\n") {
for _, raw := range rawLines {
if raw == "" { if raw == "" {
continue continue
} }
text := strings.TrimSuffix(raw, "\n") // Strip the terminator for display; also drop a trailing CR so CRLF files
// do not leak raw carriage returns into the rendered diff.
text := strings.TrimSuffix(strings.TrimSuffix(raw, "\n"), "\r")
// Record both running line numbers on every line (not just the active
// side): the opposite-side counter is the position the change sits at,
// which hunkBounds needs to compute correct @@ header start lines.
switch d.Type { switch d.Type {
case diffmatchpatch.DiffEqual: case diffmatchpatch.DiffEqual:
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine}) lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
oldLine++ oldLine++
newLine++ newLine++
case diffmatchpatch.DiffDelete: case diffmatchpatch.DiffDelete:
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine}) lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
oldLine++ oldLine++
case diffmatchpatch.DiffInsert: case diffmatchpatch.DiffInsert:
lines = append(lines, diffLine{op: d.Type, text: text, newN: newLine}) lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
newLine++ newLine++
} }
} }
} }
return lines, oldLine - 1, newLine - 1
}
// Identify indices of changed lines // diffHunkRanges returns the inclusive index ranges to emit: each changed line padded by
// 3 lines of context, with overlapping/adjacent ranges merged.
func diffHunkRanges(lines []diffLine) []indexRange {
const contextSize = 3 const contextSize = 3
var changedIndices []int
for i, l := range lines {
if l.op != diffmatchpatch.DiffEqual {
changedIndices = append(changedIndices, i)
}
}
if len(changedIndices) == 0 {
return "" // no changes
}
// Build inclusion ranges: for each changed line, include contextSize lines before/after.
// Merge overlapping or adjacent ranges (gap <= 2*contextSize = 6 context lines).
type indexRange struct{ start, end int } // inclusive
var ranges []indexRange var ranges []indexRange
for _, ci := range changedIndices { for i, l := range lines {
rStart := ci - contextSize if l.op == diffmatchpatch.DiffEqual {
if rStart < 0 { continue
rStart = 0
}
rEnd := ci + contextSize
if rEnd >= len(lines) {
rEnd = len(lines) - 1
} }
rStart := max(i-contextSize, 0)
rEnd := min(i+contextSize, len(lines)-1)
if len(ranges) > 0 && rStart <= ranges[len(ranges)-1].end+1 { if len(ranges) > 0 && rStart <= ranges[len(ranges)-1].end+1 {
// Merge with previous range ranges[len(ranges)-1].end = rEnd // merge with previous
ranges[len(ranges)-1].end = rEnd
} else { } else {
ranges = append(ranges, indexRange{rStart, rEnd}) ranges = append(ranges, indexRange{rStart, rEnd})
} }
} }
return ranges
}
// Emit unified diff // hunkBounds computes the unified-diff hunk header line numbers and counts for
var buf bytes.Buffer // lines[start:end+1]. Each line carries both running line numbers (see
buf.WriteString(fmt.Sprintf("--- %s\n", filename)) // flattenDiffLines), so the hunk start is simply the first line's numbers. When a
buf.WriteString(fmt.Sprintf("+++ %s\n", filename)) // side has zero lines in the hunk (pure insertion or deletion), the header start
// for that side follows GNU's convention: the line number *before* the change,
for _, r := range ranges { // clamped to 0 at the start of the file (e.g. "@@ -0,0 +1,N @@").
// Determine hunk header line numbers func hunkBounds(lines []diffLine, start, end int) (oldStart, oldCount, newStart, newCount int) {
var oldStart, oldCount, newStart, newCount int oldStart = lines[start].oldN
for i := r.start; i <= r.end; i++ { newStart = lines[start].newN
l := lines[i] for i := start; i <= end; i++ {
switch l.op { switch lines[i].op {
case diffmatchpatch.DiffEqual: case diffmatchpatch.DiffEqual:
if oldCount == 0 { oldCount++
oldStart = l.oldN newCount++
} case diffmatchpatch.DiffDelete:
if newCount == 0 { oldCount++
newStart = l.newN case diffmatchpatch.DiffInsert:
} newCount++
oldCount++
newCount++
case diffmatchpatch.DiffDelete:
if oldCount == 0 {
oldStart = l.oldN
}
if newCount == 0 {
// Set newStart from context or next available
newStart = l.oldN // approximate
}
oldCount++
case diffmatchpatch.DiffInsert:
if newCount == 0 {
newStart = l.newN
}
if oldCount == 0 {
oldStart = l.newN // approximate
}
newCount++
}
} }
}
if oldCount == 0 {
oldStart = max(0, oldStart-1)
}
if newCount == 0 {
newStart = max(0, newStart-1)
}
return
}
buf.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)) // writeDiffBody writes the space/-/+ body lines for one hunk, appending the git-style
// no-newline marker after the final line of any version that lacks a trailing newline.
for i := r.start; i <= r.end; i++ { func writeDiffBody(buf *bytes.Buffer, lines []diffLine, start, end int, origNoEOL, modNoEOL bool, maxOldN, maxNewN int) {
l := lines[i] for i := start; i <= end; i++ {
switch l.op { l := lines[i]
case diffmatchpatch.DiffEqual: switch l.op {
buf.WriteString(fmt.Sprintf(" %s\n", l.text)) case diffmatchpatch.DiffEqual:
case diffmatchpatch.DiffDelete: fmt.Fprintf(buf, " %s\n", l.text)
buf.WriteString(fmt.Sprintf("-%s\n", l.text)) if (origNoEOL && l.oldN == maxOldN) || (modNoEOL && l.newN == maxNewN) {
case diffmatchpatch.DiffInsert: buf.WriteString(noNewlineMarker)
buf.WriteString(fmt.Sprintf("+%s\n", l.text)) }
case diffmatchpatch.DiffDelete:
fmt.Fprintf(buf, "-%s\n", l.text)
if origNoEOL && l.oldN == maxOldN {
buf.WriteString(noNewlineMarker)
}
case diffmatchpatch.DiffInsert:
fmt.Fprintf(buf, "+%s\n", l.text)
if modNoEOL && l.newN == maxNewN {
buf.WriteString(noNewlineMarker)
} }
} }
} }
return buf.String()
} }
// resolveTextSelector finds the byte range for a text-based selection. // resolveTextSelector finds the byte range for a text-based selection.
@@ -831,57 +852,11 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
return start, end, nil return start, end, nil
} }
// applyTextEditOperation applies a text edit operation. // applyTextEditOperation applies a text-mode edit. Like AST mode, new_content is inserted
// verbatim (no auto-indentation) with its line endings normalized to the file's convention.
func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start, end int, newContent string) ([]byte, error) { func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start, end int, newContent string) ([]byte, error) {
// Detect indentation at the selection point eol := detectLineEnding(content)
indentation := detectIndentation(content, start) return spliceContent(op, content, start, end, normalizeLineEndings(newContent, eol), eol)
indentedContent := indentContent(newContent, indentation)
var result []byte
switch op {
case EditReplace:
result = append(result, content[:start]...)
result = append(result, []byte(indentedContent)...)
// Preserve trailing newline: if selection ended with \n but replacement doesn't,
// re-add it to prevent line merging
if end > start && content[end-1] == '\n' && !strings.HasSuffix(indentedContent, "\n") {
result = append(result, '\n')
}
result = append(result, content[end:]...)
case EditInsertBefore:
insertion := indentedContent
if !strings.HasSuffix(insertion, "\n") {
insertion += "\n"
}
result = append(result, content[:start]...)
result = append(result, []byte(insertion)...)
result = append(result, content[start:]...)
case EditInsertAfter:
insertion := indentedContent
// Ensure separation from preceding content
if end > 0 && content[end-1] != '\n' && !strings.HasPrefix(insertion, "\n") {
insertion = "\n" + insertion
}
// Ensure separation from following content
if !strings.HasSuffix(insertion, "\n") && end < len(content) && content[end] != '\n' {
insertion += "\n"
}
result = append(result, content[:end]...)
result = append(result, []byte(insertion)...)
result = append(result, content[end:]...)
case EditDelete:
result = append(result, content[:start]...)
result = append(result, content[end:]...)
default:
return nil, errors.NewInvalidEditError(fmt.Sprintf("unknown operation: %s", op))
}
return result, nil
} }
// truncateString truncates a string to maxLen with ellipsis. // truncateString truncates a string to maxLen with ellipsis.
@@ -889,7 +864,7 @@ func truncateString(s string, maxLen int) string {
if len(s) <= maxLen { if len(s) <= maxLen {
return s return s
} }
return s[:maxLen-3] + "..." return s[:util.RuneBoundary(s, maxLen-3)] + "..."
} }
// ValidateLanguage checks if AST editing is supported for a file. // ValidateLanguage checks if AST editing is supported for a file.
+73 -32
View File
@@ -101,6 +101,22 @@ func TestValidateEdit(t *testing.T) {
} }
} }
// A replace with empty new_content should point the caller at operation=delete.
func TestValidateReplaceEmptySuggestsDelete(t *testing.T) {
e := NewEngine(parser.NewRegistry())
err := e.validateASTEdit(&ASTEdit{
File: "test.go",
Operation: EditReplace,
Selector: ASTSelector{Kind: "function_declaration"},
})
if err == nil {
t.Fatal("expected error for replace with empty new_content")
}
if !strings.Contains(err.Error(), "operation=delete") {
t.Errorf("error should suggest operation=delete, got: %v", err)
}
}
func TestResolveSelector(t *testing.T) { func TestResolveSelector(t *testing.T) {
registry := parser.NewRegistry() registry := parser.NewRegistry()
defer registry.Close() defer registry.Close()
@@ -353,40 +369,42 @@ func Hello() {
} }
} }
func TestDetectIndentation(t *testing.T) { // An edit on a CRLF file must normalize inserted LF content to CRLF so the file
tests := []struct { // keeps a single, consistent line-ending convention (a documented guarantee).
name string func TestApplyPreservesCRLF(t *testing.T) {
content string registry := parser.NewRegistry()
want string defer registry.Close()
pos int e := NewEngine(registry)
}{
{ tmpDir := t.TempDir()
name: "no indent", tmpFile := filepath.Join(tmpDir, "crlf.go")
content: "func main() {}", content := "package main\r\n\r\nfunc Hello() {\r\n\tprintln(\"hi\")\r\n}\r\n"
pos: 0, if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
want: "", t.Fatalf("write: %v", err)
},
{
name: "tab indent",
content: "func main() {\n\tprintln(\"hello\")\n}",
pos: 15,
want: "\t",
},
{
name: "space indent",
content: "func main() {\n println(\"hello\")\n}",
pos: 18,
want: " ",
},
} }
for _, tt := range tests { _, err := e.Apply(context.Background(), &ASTEdit{
t.Run(tt.name, func(t *testing.T) { File: tmpFile,
got := detectIndentation([]byte(tt.content), tt.pos) Operation: EditReplace,
if got != tt.want { Selector: ASTSelector{Kind: "function_declaration"},
t.Errorf("detectIndentation() = %q, want %q", got, tt.want) NewContent: "func Bye() {\n\tprintln(\"bye\")\n}", // LF newlines on input
} })
}) if err != nil {
t.Fatalf("apply failed: %v", err)
}
out, err := os.ReadFile(tmpFile)
if err != nil {
t.Fatalf("read: %v", err)
}
s := string(out)
if !strings.Contains(s, "func Bye()") {
t.Fatalf("edit not applied:\n%q", s)
}
for i := 0; i < len(s); i++ {
if s[i] == '\n' && (i == 0 || s[i-1] != '\r') {
t.Fatalf("bare LF at offset %d — CRLF not preserved:\n%q", i, s)
}
} }
} }
@@ -448,6 +466,29 @@ func TestGenerateDiffLineLevelAccuracy(t *testing.T) {
} }
} }
// Hunk @@ headers must be correct at file boundaries (previously approximate).
func TestGenerateDiffHunkHeaderBounds(t *testing.T) {
e := &Engine{dmp: diffmatchpatch.New()}
tests := []struct {
name string
original string
modified string
wantHdr string
}{
{name: "insert into empty file", original: "", modified: "X\nY\n", wantHdr: "@@ -0,0 +1,2 @@"},
{name: "delete entire file", original: "a\nb\n", modified: "", wantHdr: "@@ -1,2 +0,0 @@"},
{name: "mid-file change keeps 1-based start", original: "l1\nl2\nl3\nl4\nl5\n", modified: "l1\nl2\nCHANGED\nl4\nl5\n", wantHdr: "@@ -1,5 +1,5 @@"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff := e.generateDiff(tt.original, tt.modified, "f.txt")
if !strings.Contains(diff, tt.wantHdr) {
t.Errorf("expected hunk header %q, got:\n%s", tt.wantHdr, diff)
}
})
}
}
func TestGenerateDiffNoPhantomChanges(t *testing.T) { func TestGenerateDiffNoPhantomChanges(t *testing.T) {
// Regression test: replacing a line range should not produce phantom // Regression test: replacing a line range should not produce phantom
// +/- lines for unchanged code after the edit region. // +/- lines for unchanged code after the edit region.
+172
View File
@@ -0,0 +1,172 @@
package edit
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
)
// mkEngine builds an Engine with a registry that is closed at test end.
func mkEngine(t *testing.T) *Engine {
t.Helper()
reg := parser.NewRegistry()
t.Cleanup(reg.Close)
return NewEngine(reg)
}
// applyEditFile writes content to a temp file, applies the edit, and returns the on-disk bytes.
func applyEditFile(t *testing.T, name, content string, e *ASTEdit) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatalf("write temp: %v", err)
}
e.File = p
res, err := mkEngine(t).Apply(context.Background(), e)
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Success {
t.Fatalf("apply unsuccessful: %s", res.Error)
}
got, err := os.ReadFile(p)
if err != nil {
t.Fatalf("readback: %v", err)
}
return string(got)
}
// assertAllCRLF fails if s contains any bare LF (not preceded by CR) or a doubled CR.
func assertAllCRLF(t *testing.T, s string) {
t.Helper()
if strings.Count(s, "\n") != strings.Count(s, "\r\n") {
t.Fatalf("file contains bare LF (mixed line endings): %q", s)
}
if strings.Contains(s, "\r\r") {
t.Fatalf("file contains doubled CR: %q", s)
}
}
// ---- Cluster C: text-mode edits insert new_content verbatim (no auto-indentation) ----
func TestTextEditVerbatimNoAutoIndent(t *testing.T) {
// selector_text matches a token that sits at one tab of indentation, so the old
// code detected "\t" and re-indented continuation lines of new_content. Because
// new_content is itself already indented, that produced a DOUBLE tab on line 2.
src := "func f() {\n\tOLD\n}\n"
got := applyEditFile(t, "f.txt", src, &ASTEdit{
Operation: EditReplace,
NewContent: "A\n\tB", // line 2 already carries its own tab
Selector: ASTSelector{Text: "OLD"},
})
want := "func f() {\n\tA\n\tB\n}\n"
if got != want {
t.Fatalf("text edit must insert new_content verbatim (no auto-indent).\nwant: %q\ngot: %q", want, got)
}
}
// ---- Cluster B: edits preserve the file's original line-ending convention ----
func TestASTEditPreservesCRLF(t *testing.T) {
src := "package main\r\n\r\nfunc demo() {\r\n\tprintln(\"old\")\r\n}\r\n"
got := applyEditFile(t, "demo.go", src, &ASTEdit{
Operation: EditReplace,
NewContent: "func demo() {\n\treturn\n}",
Selector: ASTSelector{Kind: "function_declaration", Name: "demo"},
})
assertAllCRLF(t, got)
if !strings.Contains(got, "func demo() {\r\n\treturn\r\n}") {
t.Fatalf("replacement not normalized to CRLF: %q", got)
}
}
func TestTextEditReplacePreservesCRLF(t *testing.T) {
src := "alpha\r\nbravo\r\ncharlie\r\n"
got := applyEditFile(t, "f.txt", src, &ASTEdit{
Operation: EditReplace,
NewContent: "BRAVO1\nBRAVO2",
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
})
assertAllCRLF(t, got)
want := "alpha\r\nBRAVO1\r\nBRAVO2\r\ncharlie\r\n"
if got != want {
t.Fatalf("CRLF text replace.\nwant: %q\ngot: %q", want, got)
}
}
func TestTextEditInsertAfterPreservesCRLF(t *testing.T) {
src := "alpha\r\nbravo\r\n"
got := applyEditFile(t, "f.txt", src, &ASTEdit{
Operation: EditInsertAfter,
NewContent: "INSERTED",
Selector: ASTSelector{AtLine: 1, LineEnd: 1},
})
assertAllCRLF(t, got)
want := "alpha\r\nINSERTED\r\nbravo\r\n"
if got != want {
t.Fatalf("CRLF insert_after.\nwant: %q\ngot: %q", want, got)
}
}
func TestLFFileStaysLFWhenNewContentHasCRLF(t *testing.T) {
src := "alpha\nbravo\ncharlie\n"
got := applyEditFile(t, "f.txt", src, &ASTEdit{
Operation: EditReplace,
NewContent: "B1\r\nB2", // stray CRLF in new_content must be normalized to the LF file
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
})
if strings.Contains(got, "\r") {
t.Fatalf("LF file must not gain CR: %q", got)
}
want := "alpha\nB1\nB2\ncharlie\n"
if got != want {
t.Fatalf("LF normalization.\nwant: %q\ngot: %q", want, got)
}
}
// ---- Cluster D: diff rendering ----
func TestDiffMarksNoNewlineAtEOF(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f.txt")
if err := os.WriteFile(p, []byte("alpha\nbravo"), 0o600); err != nil { // no trailing newline
t.Fatalf("write: %v", err)
}
res, err := mkEngine(t).Preview(context.Background(), &ASTEdit{
File: p,
Operation: EditReplace,
NewContent: "BRAVO",
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
})
if err != nil || !res.Success {
t.Fatalf("preview failed: %v %s", err, res.Error)
}
if !strings.Contains(res.Diff, "No newline at end of file") {
t.Fatalf("diff should mark missing final newline, got:\n%s", res.Diff)
}
}
func TestDiffHasNoRawCarriageReturn(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f.txt")
if err := os.WriteFile(p, []byte("alpha\r\nbravo\r\n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
res, err := mkEngine(t).Preview(context.Background(), &ASTEdit{
File: p,
Operation: EditReplace,
NewContent: "BRAVO",
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
})
if err != nil || !res.Success {
t.Fatalf("preview failed: %v %s", err, res.Error)
}
if strings.Contains(res.Diff, "\r") {
t.Fatalf("diff display must not contain raw CR, got:\n%q", res.Diff)
}
}
+43 -3
View File
@@ -22,6 +22,10 @@ const ProcessKillTimeout = 5 * time.Second
// StderrBufferSize is the buffer size for draining stderr. // StderrBufferSize is the buffer size for draining stderr.
const StderrBufferSize = 1024 const StderrBufferSize = 1024
// StderrCaptureMax is the maximum number of recent stderr bytes retained for
// diagnostics (e.g. to explain an init failure). Older bytes are dropped.
const StderrCaptureMax = 4096
// Client represents an LSP client connection. // Client represents an LSP client connection.
type Client struct { type Client struct {
stdin io.WriteCloser stdin io.WriteCloser
@@ -35,6 +39,8 @@ type Client struct {
runningMu sync.RWMutex runningMu sync.RWMutex
stopOnce sync.Once stopOnce sync.Once
mu sync.Mutex mu sync.Mutex
stderrMu sync.Mutex
stderrTail []byte // most recent stderr, capped at StderrCaptureMax (diagnostics)
running bool running bool
} }
@@ -121,6 +127,12 @@ func NewClient(cmd *exec.Cmd) (*Client, error) {
// drainStderr consumes stderr output to prevent the LSP server from blocking. // drainStderr consumes stderr output to prevent the LSP server from blocking.
// LSP servers may write diagnostic messages to stderr which we discard. // LSP servers may write diagnostic messages to stderr which we discard.
//
// Cancellation note: the goroutine parks in the blocking stderr.Read below, so
// closing c.done does not interrupt it directly — termination relies on the
// read returning an error when the pipe reaches EOF (the process is killed on
// Close). The c.done check is only a best-effort fast path for the case where
// done is already closed between reads.
func (c *Client) drainStderr() { func (c *Client) drainStderr() {
buf := make([]byte, StderrBufferSize) buf := make([]byte, StderrBufferSize)
for { for {
@@ -129,14 +141,35 @@ func (c *Client) drainStderr() {
return return
default: default:
} }
// Read and discard stderr output n, err := c.stderr.Read(buf)
_, err := c.stderr.Read(buf) if n > 0 {
c.captureStderr(buf[:n])
}
if err != nil { if err != nil {
return return
} }
} }
} }
// captureStderr appends p to the retained stderr tail, keeping at most
// StderrCaptureMax bytes (dropping the oldest).
func (c *Client) captureStderr(p []byte) {
c.stderrMu.Lock()
defer c.stderrMu.Unlock()
c.stderrTail = append(c.stderrTail, p...)
if len(c.stderrTail) > StderrCaptureMax {
c.stderrTail = c.stderrTail[len(c.stderrTail)-StderrCaptureMax:]
}
}
// RecentStderr returns the retained tail of the server's stderr, trimmed. Useful
// for surfacing why an LSP server failed to start.
func (c *Client) RecentStderr() string {
c.stderrMu.Lock()
defer c.stderrMu.Unlock()
return strings.TrimSpace(string(c.stderrTail))
}
// Call sends a request and waits for a response. // Call sends a request and waits for a response.
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) { func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
c.runningMu.RLock() c.runningMu.RLock()
@@ -315,7 +348,14 @@ func (c *Client) readLoop() {
if err := json.Unmarshal(body, &resp); err == nil && resp.ID != 0 { if err := json.Unmarshal(body, &resp); err == nil && resp.ID != 0 {
c.mu.Lock() c.mu.Lock()
if ch, ok := c.pending[resp.ID]; ok { if ch, ok := c.pending[resp.ID]; ok {
ch <- &resp // Non-blocking send: the pending channel is buffered(1) and read
// exactly once. A misbehaving server that sends a duplicate
// response for the same id must not block here while holding c.mu
// (which would deadlock all future dispatches).
select {
case ch <- &resp:
default:
}
} }
c.mu.Unlock() c.mu.Unlock()
continue continue
+28
View File
@@ -0,0 +1,28 @@
package lsp
import (
"strings"
"testing"
)
// drainStderr retains a bounded tail of server stderr for diagnostics; capture
// must keep recent bytes and never grow past StderrCaptureMax.
func TestClientStderrCapture(t *testing.T) {
c := &Client{}
if c.RecentStderr() != "" {
t.Errorf("expected empty stderr initially, got %q", c.RecentStderr())
}
c.captureStderr([]byte("gopls: command not found\n"))
if !strings.Contains(c.RecentStderr(), "command not found") {
t.Errorf("expected captured stderr, got %q", c.RecentStderr())
}
// Exceed the cap: only the most recent StderrCaptureMax bytes are kept.
big := strings.Repeat("x", StderrCaptureMax*2)
c.captureStderr([]byte(big))
if got := len(c.RecentStderr()); got > StderrCaptureMax {
t.Errorf("stderr tail not bounded: len=%d, want <= %d", got, StderrCaptureMax)
}
}
+78
View File
@@ -0,0 +1,78 @@
package lsp
import (
"encoding/json"
"testing"
)
// TestParseDefinitionResult covers every LSP definition result shape: single
// Location, Location[], single LocationLink, LocationLink[], plus empty/error
// cases. The LocationLink shapes are the regression target — we advertise
// LinkSupport, so gopls/rust-analyzer return targetUri/targetRange, which a
// naive []Location unmarshal would silently turn into zero-value locations.
func TestParseDefinitionResult(t *testing.T) {
tests := []struct {
name string
raw string
wantURIs []string
wantErr bool
}{
{
name: "single Location",
raw: `{"uri":"file:///a.go","range":{"start":{"line":4,"character":2},"end":{"line":4,"character":8}}}`,
wantURIs: []string{"file:///a.go"},
},
{
name: "Location array",
raw: `[{"uri":"file:///a.go","range":{"start":{"line":1,"character":0},"end":{"line":1,"character":3}}},{"uri":"file:///b.go","range":{"start":{"line":2,"character":0},"end":{"line":2,"character":3}}}]`,
wantURIs: []string{"file:///a.go", "file:///b.go"},
},
{
name: "single LocationLink",
raw: `{"targetUri":"file:///c.go","targetRange":{"start":{"line":9,"character":0},"end":{"line":12,"character":1}}}`,
wantURIs: []string{"file:///c.go"},
},
{
name: "LocationLink array",
raw: `[{"targetUri":"file:///d.go","targetRange":{"start":{"line":0,"character":0},"end":{"line":0,"character":5}}}]`,
wantURIs: []string{"file:///d.go"},
},
{
name: "empty array",
raw: `[]`,
wantURIs: []string{},
},
{
name: "garbage",
raw: `"not-a-location"`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
locs, err := parseDefinitionResult(json.RawMessage(tt.raw))
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got locations %+v", locs)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(locs) != len(tt.wantURIs) {
t.Fatalf("got %d locations, want %d (%+v)", len(locs), len(tt.wantURIs), locs)
}
for i, want := range tt.wantURIs {
if locs[i].URI != want {
t.Errorf("location[%d].URI = %q, want %q", i, locs[i].URI, want)
}
}
// LocationLink ranges must be carried over, not zeroed.
if tt.name == "single LocationLink" && locs[0].Range.Start.Line != 9 {
t.Errorf("LocationLink targetRange not preserved: got start line %d, want 9", locs[0].Range.Start.Line)
}
})
}
}
+53 -9
View File
@@ -212,10 +212,16 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
// Initialize server // Initialize server
if err := m.initializeServer(ctx, newSrv); err != nil { if err := m.initializeServer(ctx, newSrv); err != nil {
newSrv.initErr = err newSrv.initErr = err
return nil, errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err). wrapped := errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
WithContext("language", string(lang)). WithContext("language", string(lang)).
WithContext("command", config.Command[0]). WithContext("command", config.Command[0]).
WithRemediation("Check LSP server logs for initialization errors") WithRemediation("Check LSP server logs for initialization errors")
// Surface the server's own stderr (e.g. "command not found", missing
// module) so the failure is diagnosable instead of opaque.
if stderr := client.RecentStderr(); stderr != "" {
wrapped = wrapped.WithContext("stderr", stderr)
}
return nil, wrapped
} }
// Mark as successfully initialized to prevent cleanup // Mark as successfully initialized to prevent cleanup
@@ -358,17 +364,55 @@ func (m *Manager) Definition(ctx context.Context, file string, line, col int) ([
return nil, nil return nil, nil
} }
// Result can be Location, []Location, or []LocationLink return parseDefinitionResult(resp.Result)
var locations []Location }
if err := json.Unmarshal(resp.Result, &locations); err != nil {
// Try single location // parseDefinitionResult decodes a textDocument/definition result. Per LSP the
var single Location // result may be Location, Location[], LocationLink, or LocationLink[]. Because
if err := json.Unmarshal(resp.Result, &single); err == nil { // we advertise LinkSupport (see initialize), servers such as gopls and
locations = []Location{single} // rust-analyzer return the LocationLink shape (targetUri/targetRange). A naive
// unmarshal into []Location silently succeeds with zero-value URIs/ranges, so
// decode into a unified shape that captures both and normalize to Location.
func parseDefinitionResult(raw json.RawMessage) ([]Location, error) {
// locOrLink captures both the Location (uri/range) and LocationLink
// (targetUri/targetRange) field sets; whichever is populated wins.
type locOrLink struct {
URI string `json:"uri"`
Range Range `json:"range"`
TargetURI string `json:"targetUri"`
TargetRange Range `json:"targetRange"`
}
toLocation := func(l locOrLink) (Location, bool) {
if l.URI != "" {
return Location{URI: l.URI, Range: l.Range}, true
} }
if l.TargetURI != "" {
return Location{URI: l.TargetURI, Range: l.TargetRange}, true
}
return Location{}, false
} }
return locations, nil // Array form (Location[] or LocationLink[]).
var arr []locOrLink
if err := json.Unmarshal(raw, &arr); err == nil {
locations := make([]Location, 0, len(arr))
for _, l := range arr {
if loc, ok := toLocation(l); ok {
locations = append(locations, loc)
}
}
return locations, nil
}
// Single object form (Location or LocationLink).
var single locOrLink
if err := json.Unmarshal(raw, &single); err != nil {
return nil, fmt.Errorf("failed to parse definition result: %w", err)
}
if loc, ok := toLocation(single); ok {
return []Location{loc}, nil
}
return nil, nil
} }
// References finds all references to the symbol at the given position. // References finds all references to the symbol at the given position.
+22 -17
View File
@@ -33,8 +33,13 @@ func (c *Counter) Inc() {
c.value.Add(1) c.value.Add(1)
} }
// Add adds the given value to the counter. // Add adds the given value to the counter. Counters are monotonic, so a
// negative delta (which would corrupt rate()-style calculations downstream) is
// ignored rather than applied.
func (c *Counter) Add(delta int64) { func (c *Counter) Add(delta int64) {
if delta < 0 {
return
}
c.value.Add(delta) c.value.Add(delta)
} }
@@ -278,27 +283,27 @@ func (r *Registry) Expose() string {
// Export counters // Export counters
for _, c := range r.counters { for _, c := range r.counters {
if c.help != "" { if c.help != "" {
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", c.name, c.help)) fmt.Fprintf(&sb, "# HELP %s %s\n", c.name, c.help)
} }
sb.WriteString(fmt.Sprintf("# TYPE %s counter\n", c.name)) fmt.Fprintf(&sb, "# TYPE %s counter\n", c.name)
sb.WriteString(fmt.Sprintf("%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load())) fmt.Fprintf(&sb, "%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load())
} }
// Export gauges // Export gauges
for _, g := range r.gauges { for _, g := range r.gauges {
if g.help != "" { if g.help != "" {
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", g.name, g.help)) fmt.Fprintf(&sb, "# HELP %s %s\n", g.name, g.help)
} }
sb.WriteString(fmt.Sprintf("# TYPE %s gauge\n", g.name)) fmt.Fprintf(&sb, "# TYPE %s gauge\n", g.name)
sb.WriteString(fmt.Sprintf("%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load())) fmt.Fprintf(&sb, "%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load())
} }
// Export histograms // Export histograms
for _, h := range r.histograms { for _, h := range r.histograms {
if h.help != "" { if h.help != "" {
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", h.name, h.help)) fmt.Fprintf(&sb, "# HELP %s %s\n", h.name, h.help)
} }
sb.WriteString(fmt.Sprintf("# TYPE %s histogram\n", h.name)) fmt.Fprintf(&sb, "# TYPE %s histogram\n", h.name)
// Cumulative bucket counts // Cumulative bucket counts
var cumulative int64 var cumulative int64
@@ -306,26 +311,26 @@ func (r *Registry) Expose() string {
cumulative += h.counts[i].Load() cumulative += h.counts[i].Load()
labelStr := formatLabels(h.labels) labelStr := formatLabels(h.labels)
if labelStr == "" { if labelStr == "" {
sb.WriteString(fmt.Sprintf("%s_bucket{le=\"%g\"} %d\n", h.name, bound, cumulative)) fmt.Fprintf(&sb, "%s_bucket{le=\"%g\"} %d\n", h.name, bound, cumulative)
} else { } else {
// Insert le label into existing labels // Insert le label into existing labels
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name, fmt.Fprintf(&sb, "%s_bucket%s %d\n", h.name,
strings.Replace(labelStr, "}", fmt.Sprintf(",le=\"%g\"}", bound), 1), cumulative)) strings.Replace(labelStr, "}", fmt.Sprintf(",le=\"%g\"}", bound), 1), cumulative)
} }
} }
// +Inf bucket // +Inf bucket
cumulative += h.counts[len(h.buckets)].Load() cumulative += h.counts[len(h.buckets)].Load()
labelStr := formatLabels(h.labels) labelStr := formatLabels(h.labels)
if labelStr == "" { if labelStr == "" {
sb.WriteString(fmt.Sprintf("%s_bucket{le=\"+Inf\"} %d\n", h.name, cumulative)) fmt.Fprintf(&sb, "%s_bucket{le=\"+Inf\"} %d\n", h.name, cumulative)
} else { } else {
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name, fmt.Fprintf(&sb, "%s_bucket%s %d\n", h.name,
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative)) strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative)
} }
// Sum and count // Sum and count
sb.WriteString(fmt.Sprintf("%s_sum%s %g\n", h.name, formatLabels(h.labels), h.Sum())) fmt.Fprintf(&sb, "%s_sum%s %g\n", h.name, formatLabels(h.labels), h.Sum())
sb.WriteString(fmt.Sprintf("%s_count%s %d\n", h.name, formatLabels(h.labels), h.count.Load())) fmt.Fprintf(&sb, "%s_count%s %d\n", h.name, formatLabels(h.labels), h.count.Load())
} }
return sb.String() return sb.String()
+10
View File
@@ -71,6 +71,16 @@ func TestCounter(t *testing.T) {
} }
} }
// Counters are monotonic: a negative Add must be ignored, not applied.
func TestCounterRejectsNegativeAdd(t *testing.T) {
c := NewCounter("mono_counter", "test", nil)
c.Add(5)
c.Add(-3)
if got := c.Value(); got != 5 {
t.Errorf("negative Add should be ignored: Value() = %d, want 5", got)
}
}
func TestCounterConcurrency(t *testing.T) { func TestCounterConcurrency(t *testing.T) {
c := NewCounter("concurrent_counter", "test", nil) c := NewCounter("concurrent_counter", "test", nil)
var wg sync.WaitGroup var wg sync.WaitGroup
+8 -2
View File
@@ -3,6 +3,7 @@ package parser
import ( import (
"regexp" "regexp"
"sort"
"strings" "strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol" "github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
@@ -530,11 +531,16 @@ func FormatDocComment(doc *DocComment) string {
written[t] = true written[t] = true
} }
for tagName, val := range doc.Tags { remaining := make([]string, 0, len(doc.Tags))
for tagName := range doc.Tags {
if !written[tagName] { if !written[tagName] {
sb.WriteString("@" + tagName + " " + val + "\n") remaining = append(remaining, tagName)
} }
} }
sort.Strings(remaining) // deterministic output (map order is random)
for _, tagName := range remaining {
sb.WriteString("@" + tagName + " " + doc.Tags[tagName] + "\n")
}
} }
return strings.TrimSpace(sb.String()) return strings.TrimSpace(sb.String())
+8 -4
View File
@@ -48,10 +48,13 @@ type Registry struct {
} }
// CachedTree stores a parsed tree with its metadata. // CachedTree stores a parsed tree with its metadata.
// Content is not stored to reduce memory usage. // Content is not stored to reduce memory usage. Syntax errors are a pure
// function of the tree (extractErrors ignores content), so they are cached
// alongside it to avoid re-walking the whole tree on every cache hit.
type CachedTree struct { type CachedTree struct {
Tree *sitter.Tree Tree *sitter.Tree
Language protocol.Language Language protocol.Language
Errors []SyntaxError
} }
// ParseResult contains the result of parsing a file. // ParseResult contains the result of parsing a file.
@@ -202,11 +205,10 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
hash := fmt.Sprintf("%s:%016x", string(lang), xxhash.Sum64(content)) hash := fmt.Sprintf("%s:%016x", string(lang), xxhash.Sum64(content))
if cached, ok := r.cache.Get(hash); ok && cached.Language == lang { if cached, ok := r.cache.Get(hash); ok && cached.Language == lang {
r.cacheHits.Add(1) r.cacheHits.Add(1)
errors := extractErrors(cached.Tree.RootNode(), content)
return &ParseResult{ return &ParseResult{
Tree: cached.Tree, Tree: cached.Tree,
Language: lang, Language: lang,
Errors: errors, Errors: cached.Errors,
Content: content, Content: content,
}, nil }, nil
} }
@@ -242,10 +244,12 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
// Extract syntax errors // Extract syntax errors
errors := extractErrors(tree.RootNode(), content) errors := extractErrors(tree.RootNode(), content)
// Cache result (LRU cache handles eviction automatically) // Cache result (LRU cache handles eviction automatically).
// Errors are cached so cache hits skip a full tree walk.
r.cache.Add(hash, &CachedTree{ r.cache.Add(hash, &CachedTree{
Tree: tree, Tree: tree,
Language: lang, Language: lang,
Errors: errors,
}) })
return &ParseResult{ return &ParseResult{
+37
View File
@@ -172,6 +172,43 @@ func TestParseWithSyntaxErrors(t *testing.T) {
} }
} }
// TestParseCachedErrorsMatch verifies that the syntax errors returned on a
// cache hit are identical to those computed on the initial parse. Errors are
// cached alongside the tree (they are a pure function of the tree), so the hot
// cache-hit path must not lose or alter them.
func TestParseCachedErrorsMatch(t *testing.T) {
r := NewRegistry()
defer r.Close()
content := []byte("package main\n\nfunc main( {}\n") // missing closing paren
ctx := context.Background()
first, err := r.Parse(ctx, "test.go", content)
if err != nil {
t.Fatalf("first parse failed: %v", err)
}
if len(first.Errors) == 0 {
t.Fatal("expected syntax errors on first parse")
}
second, err := r.Parse(ctx, "test.go", content)
if err != nil {
t.Fatalf("cached parse failed: %v", err)
}
if second.Tree != first.Tree {
t.Fatal("expected cache hit (same tree pointer)")
}
if len(second.Errors) != len(first.Errors) {
t.Fatalf("cached errors count = %d, want %d", len(second.Errors), len(first.Errors))
}
for i := range first.Errors {
if second.Errors[i] != first.Errors[i] {
t.Errorf("cached error[%d] = %+v, want %+v", i, second.Errors[i], first.Errors[i])
}
}
}
func TestIsBinary(t *testing.T) { func TestIsBinary(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+39 -11
View File
@@ -36,22 +36,23 @@ func SkeletonFile(ctx context.Context, reg *Registry, filename string, content [
default: default:
// TODO: skeleton for c, cpp, elixir, html, vue — fall back to symbols_only // TODO: skeleton for c, cpp, elixir, html, vue — fall back to symbols_only
syms := ExtractSymbols(result.Tree, content, lang, filename) syms := ExtractSymbols(result.Tree, content, lang, filename)
return renderSymbolsOnly(syms, filename, lang, content), false, nil return renderSymbolsOnly(syms, lang), false, nil
} }
} }
// renderSymbolsOnly renders a simple symbol list (fallback for unsupported languages). // renderSymbolsOnly renders a compact symbol list (fallback for unsupported
func renderSymbolsOnly(syms []protocol.Symbol, _ string, lang protocol.Language, content []byte) string { // languages). It deliberately does not fabricate "<source line> { ... }" output:
lines := strings.Split(string(content), "\n") // the raw start line plus a guessed brace is wrong for brace-less languages (e.g.
// Elixir's "def f do", HTML) and only wastes tokens. The output is not valid
// source, so it drops "// " comment framing and uses a dense "kind name :line"
// form to minimise tokens. The symbol list (kind, name, line, doc) is accurate.
func renderSymbolsOnly(syms []protocol.Symbol, lang protocol.Language) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("// skeleton unavailable for %s — symbol list only\n", lang)) fmt.Fprintf(&sb, "symbols only (no skeleton for %s):\n", lang)
for _, s := range syms { for _, s := range syms {
sb.WriteString(fmt.Sprintf("// %s %s (line %d)\n", s.Kind, s.Name, s.Location.Line)) fmt.Fprintf(&sb, "%s %s :%d\n", s.Kind, s.Name, s.Location.Line)
if s.Doc != "" { if s.Doc != "" {
sb.WriteString(fmt.Sprintf("// doc: %s\n", s.Doc)) fmt.Fprintf(&sb, " doc: %s\n", s.Doc)
}
if s.Location.Line >= 1 && s.Location.Line <= len(lines) {
sb.WriteString(lines[s.Location.Line-1] + " { ... }\n")
} }
} }
return sb.String() return sb.String()
@@ -216,7 +217,34 @@ func skeletonPythonNode(node *sitter.Node, content []byte, sb *strings.Builder,
skeletonPythonNode(child, content, sb, indent) skeletonPythonNode(child, content, sb, indent)
} }
return return
case "function_definition", "decorated_definition": case "decorated_definition":
// Emit the decorator line(s), then render the wrapped definition so its
// signature (def/class) is preserved — taking lines[0] of the whole node
// would keep only the first decorator and drop the signature entirely.
var def *sitter.Node
for i := 0; i < int(node.NamedChildCount()); i++ {
if child := node.NamedChild(i); child != nil {
if t := child.Type(); t == "function_definition" || t == "class_definition" {
def = child
break
}
}
}
if def != nil {
decText := strings.TrimRight(string(content[node.StartByte():def.StartByte()]), "\n")
for _, dl := range strings.Split(decText, "\n") {
if strings.TrimSpace(dl) == "" {
continue
}
sb.WriteString(indent)
sb.WriteString(dl)
sb.WriteString("\n")
}
skeletonPythonNode(def, content, sb, indent)
return
}
fallthrough
case "function_definition":
nodeText := string(content[node.StartByte():node.EndByte()]) nodeText := string(content[node.StartByte():node.EndByte()])
lines := strings.SplitN(nodeText, "\n", 2) lines := strings.SplitN(nodeText, "\n", 2)
sb.WriteString(indent) sb.WriteString(indent)
+48
View File
@@ -0,0 +1,48 @@
package parser
import (
"context"
"strings"
"testing"
)
// A decorated Python function must keep BOTH its decorator(s) and its def
// signature in the skeleton — previously only the first decorator survived and
// the signature was dropped.
func TestSkeletonPythonDecoratedFunction(t *testing.T) {
r := NewRegistry()
defer r.Close()
src := "@app.route(\"/\")\n" +
"def home():\n" +
" return \"hi\"\n"
out, _, err := SkeletonFile(context.Background(), r, "app.py", []byte(src))
if err != nil {
t.Fatalf("SkeletonFile error: %v", err)
}
if !strings.Contains(out, "@app.route(\"/\")") {
t.Errorf("skeleton lost decorator, got:\n%s", out)
}
if !strings.Contains(out, "def home():") {
t.Errorf("skeleton lost def signature, got:\n%s", out)
}
if strings.Contains(out, "return \"hi\"") {
t.Errorf("skeleton should elide the body, got:\n%s", out)
}
}
// A plain (undecorated) Python function still renders its signature + body stub.
func TestSkeletonPythonPlainFunction(t *testing.T) {
r := NewRegistry()
defer r.Close()
src := "def add(a, b):\n return a + b\n"
out, _, err := SkeletonFile(context.Background(), r, "m.py", []byte(src))
if err != nil {
t.Fatalf("SkeletonFile error: %v", err)
}
if !strings.Contains(out, "def add(a, b):") {
t.Errorf("skeleton lost signature, got:\n%s", out)
}
}
+181 -45
View File
@@ -59,20 +59,60 @@ func StripContent(content string, flags []StripFlag, lang protocol.Language) Str
return StripResult{Content: content, Stripped: stripped} return StripResult{Content: content, Stripped: stripped}
} }
// stripLicense removes a leading block comment that looks like a license header. // containsLicenseKeyword reports whether lowercased text looks like a license
// A comment qualifies if it contains "copyright", "license", or "spdx-license-identifier" (case-insensitive). // header (contains "copyright", "license", or an SPDX identifier).
func containsLicenseKeyword(lower string) bool {
return strings.Contains(lower, "copyright") ||
strings.Contains(lower, "license") ||
strings.Contains(lower, "spdx-license-identifier")
}
// peelLicensePrefix splits off a leading run of lines that must be preserved
// above a license header: a shebang (#!) on the first line and/or Go
// build-constraint lines (//go:build, // +build). Returns the preserved prefix
// (with terminators) and the remaining content. With no such lines, prefix is "".
func peelLicensePrefix(content string) (prefix, rest string) {
lines := strings.SplitAfter(content, "\n")
n := 0
for n < len(lines) {
t := strings.TrimSpace(lines[n])
isShebang := n == 0 && strings.HasPrefix(t, "#!")
isBuild := strings.HasPrefix(t, "//go:build") || strings.HasPrefix(t, "// +build")
if !isShebang && !isBuild {
break
}
n++
}
if n == 0 {
return "", content
}
return strings.Join(lines[:n], ""), strings.Join(lines[n:], "")
}
// stripLicense removes a leading license header comment. A comment qualifies if
// it contains a license keyword (see containsLicenseKeyword). A leading shebang
// or Go build-constraint block is preserved above the license rather than
// consumed or blocking detection.
func stripLicense(content string) (string, bool) { func stripLicense(content string) (string, bool) {
trimmed := strings.TrimLeft(content, " \t\n\r") prefix, rest := peelLicensePrefix(content)
next, removed := stripLeadingLicenseComment(rest)
if !removed {
return content, false
}
return prefix + next, true
}
// stripLeadingLicenseComment removes a license header at the top of s, handling
// C-style /* */ blocks, contiguous // line comments, and contiguous # comments.
func stripLeadingLicenseComment(s string) (string, bool) {
trimmed := strings.TrimLeft(s, " \t\n\r")
// C-style block comment at top // C-style block comment at top
if strings.HasPrefix(trimmed, "/*") { if strings.HasPrefix(trimmed, "/*") {
end := strings.Index(trimmed, "*/") end := strings.Index(trimmed, "*/")
if end >= 0 { if end >= 0 {
candidate := trimmed[:end+2] candidate := trimmed[:end+2]
lower := strings.ToLower(candidate) if containsLicenseKeyword(strings.ToLower(candidate)) {
if strings.Contains(lower, "copyright") ||
strings.Contains(lower, "license") ||
strings.Contains(lower, "spdx-license-identifier") {
rest := trimmed[end+2:] rest := trimmed[end+2:]
// Consume trailing newline(s) // Consume trailing newline(s)
rest = strings.TrimLeft(rest, "\r\n") rest = strings.TrimLeft(rest, "\r\n")
@@ -81,30 +121,44 @@ func stripLicense(content string) (string, bool) {
} }
} }
// Python/hash-style leading comment block // "//" line-comment header (Go/C/TS). Only contiguous "//" lines belong to
if strings.HasPrefix(trimmed, "#") { // the header; a non-comment line ends it and is preserved.
if strings.HasPrefix(trimmed, "//") {
lines := strings.Split(trimmed, "\n") lines := strings.Split(trimmed, "\n")
var commentLines []string var commentLines, rest []string
var rest []string
inComment := true
for i, l := range lines { for i, l := range lines {
if inComment && (strings.HasPrefix(l, "#") || strings.TrimSpace(l) == "") { if strings.HasPrefix(strings.TrimSpace(l), "//") {
commentLines = append(commentLines, l) commentLines = append(commentLines, l)
} else { continue
rest = lines[i:]
break
} }
rest = lines[i:]
break
} }
block := strings.Join(commentLines, "\n") if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
lower := strings.ToLower(block)
if strings.Contains(lower, "copyright") ||
strings.Contains(lower, "license") ||
strings.Contains(lower, "spdx-license-identifier") {
return strings.Join(rest, "\n"), true return strings.Join(rest, "\n"), true
} }
} }
return content, false // Python/hash-style leading comment block. Only contiguous "#" lines belong to the
// header; a blank line ends it and is preserved as a separator (rather than being
// greedily swallowed and collapsed away).
if strings.HasPrefix(trimmed, "#") {
lines := strings.Split(trimmed, "\n")
var commentLines, rest []string
for i, l := range lines {
if strings.HasPrefix(l, "#") {
commentLines = append(commentLines, l)
continue
}
rest = lines[i:]
break
}
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
return strings.Join(rest, "\n"), true
}
}
return s, false
} }
// stripImports removes top-of-file import blocks, language-specific. // stripImports removes top-of-file import blocks, language-specific.
@@ -148,7 +202,10 @@ func stripGoImports(content string) (string, bool) {
} }
continue continue
} }
if strings.HasPrefix(trimLine, `import "`) || strings.HasPrefix(trimLine, "import `") { // Single import spec in any form: import "path", import `path`,
// import alias "path", import _ "path", import . "path".
if strings.HasPrefix(trimLine, "import ") &&
(strings.Contains(trimLine, `"`) || strings.Contains(trimLine, "`")) {
removed = true removed = true
i++ i++
continue continue
@@ -162,15 +219,45 @@ func stripGoImports(content string) (string, bool) {
return strings.Join(out, "\n"), true return strings.Join(out, "\n"), true
} }
// stripTSImports removes TypeScript/JavaScript "import ... from ..." and "require(...)" lines. // tsImportNeedsContinuation reports whether an import line (known to start an
// import) continues onto following lines. A multi-line named import opens a
// brace and only reaches its module specifier ("from '...'") on a later line.
func tsImportNeedsContinuation(line string) bool {
if strings.Contains(line, "from ") || strings.HasSuffix(line, ";") {
return false
}
// Side-effect import (import '...' / import "...") is single-line.
rest := strings.TrimSpace(strings.TrimPrefix(line, "import"))
if strings.HasPrefix(rest, "'") || strings.HasPrefix(rest, `"`) {
return false
}
return true
}
// stripTSImports removes TypeScript/JavaScript "import ... from ..." (including
// multi-line named imports) and destructured require() lines.
func stripTSImports(content string) (string, bool) { func stripTSImports(content string) (string, bool) {
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
var out []string var out []string
removed := false removed := false
inMulti := false
for _, l := range lines { for _, l := range lines {
trimLine := strings.TrimSpace(l) trimLine := strings.TrimSpace(l)
if strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "const {") && strings.Contains(trimLine, "require(") { if inMulti {
removed = true removed = true
// The module-specifier line ("from '...'") or a statement terminator ends it.
if strings.Contains(trimLine, "from ") || strings.HasSuffix(trimLine, ";") {
inMulti = false
}
continue
}
isImport := strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "import{")
isRequire := strings.HasPrefix(trimLine, "const {") && strings.Contains(trimLine, "require(")
if isImport || isRequire {
removed = true
if isImport && tsImportNeedsContinuation(trimLine) {
inMulti = true
}
continue continue
} }
out = append(out, l) out = append(out, l)
@@ -240,60 +327,109 @@ func stripBlockComments(content string, lang protocol.Language) (string, bool) {
return stripCStyleBlockComments(content) return stripCStyleBlockComments(content)
} }
// stripCStyleBlockComments removes /* ... */ from content. // trimTrailingLineWhitespace drops trailing spaces/tabs from out (back to, but not past,
// the previous newline). Used when a standalone comment line is removed so its leading
// indentation does not linger as a whitespace-only line.
func trimTrailingLineWhitespace(out []byte) []byte {
for len(out) > 0 && (out[len(out)-1] == ' ' || out[len(out)-1] == '\t') {
out = out[:len(out)-1]
}
return out
}
// skipLineTail advances i over trailing spaces/tabs and a CR, then a single LF — i.e. the
// remainder of a line after a standalone comment's closer, including its \n or \r\n
// terminator. Returns the new index.
func skipLineTail(content string, i int) int {
for i < len(content) && (content[i] == ' ' || content[i] == '\t' || content[i] == '\r') {
i++
}
if i < len(content) && content[i] == '\n' {
i++
}
return i
}
// stripCStyleBlockComments removes /* ... */ comments. A comment that occupies a whole
// line (only whitespace before it) is removed together with that line's indentation and
// terminator; an inline comment (code precedes it) is removed in place, leaving the
// surrounding line — and crucially its terminator — intact so lines are never merged.
func stripCStyleBlockComments(content string) (string, bool) { func stripCStyleBlockComments(content string) (string, bool) {
removed := false removed := false
var sb strings.Builder out := make([]byte, 0, len(content))
lineHasNonSpace := false
i := 0 i := 0
for i < len(content) { for i < len(content) {
if i+1 < len(content) && content[i] == '/' && content[i+1] == '*' { if i+1 < len(content) && content[i] == '/' && content[i+1] == '*' {
// find closing */ if end := strings.Index(content[i+2:], "*/"); end >= 0 {
end := strings.Index(content[i+2:], "*/")
if end >= 0 {
removed = true removed = true
// advance past */ standalone := !lineHasNonSpace
i = i + 2 + end + 2 i = i + 2 + end + 2 // advance past closing */
// consume trailing newline if standalone {
if i < len(content) && content[i] == '\n' { out = trimTrailingLineWhitespace(out)
i++ i = skipLineTail(content, i)
lineHasNonSpace = false
} }
continue continue
} }
} }
sb.WriteByte(content[i]) c := content[i]
switch c {
case '\n':
lineHasNonSpace = false
case ' ', '\t', '\r':
// whitespace: does not mark the line as having content
default:
lineHasNonSpace = true
}
out = append(out, c)
i++ i++
} }
if !removed { if !removed {
return content, false return content, false
} }
return sb.String(), true return string(out), true
} }
// stripPythonDocstrings removes triple-quoted strings (""" and ”'). // stripPythonDocstrings removes triple-quoted strings (""" and ”'). As with block
// comments, a standalone docstring line is removed along with its indentation and
// terminator, while an inline triple-quoted string leaves its line's terminator intact.
func stripPythonDocstrings(content string) (string, bool) { func stripPythonDocstrings(content string) (string, bool) {
removed := false removed := false
var sb strings.Builder out := make([]byte, 0, len(content))
lineHasNonSpace := false
i := 0 i := 0
for i < len(content) { for i < len(content) {
if i+2 < len(content) { if i+2 < len(content) {
triple := content[i : i+3] triple := content[i : i+3]
if triple == `"""` || triple == `'''` { if triple == `"""` || triple == `'''` {
end := strings.Index(content[i+3:], triple) if end := strings.Index(content[i+3:], triple); end >= 0 {
if end >= 0 {
removed = true removed = true
standalone := !lineHasNonSpace
i = i + 3 + end + 3 i = i + 3 + end + 3
if i < len(content) && content[i] == '\n' { if standalone {
i++ out = trimTrailingLineWhitespace(out)
i = skipLineTail(content, i)
lineHasNonSpace = false
} }
continue continue
} }
} }
} }
sb.WriteByte(content[i]) c := content[i]
switch c {
case '\n':
lineHasNonSpace = false
case ' ', '\t', '\r':
// whitespace: does not mark the line as having content
default:
lineHasNonSpace = true
}
out = append(out, c)
i++ i++
} }
if !removed { if !removed {
return content, false return content, false
} }
return sb.String(), true return string(out), true
} }
+100
View File
@@ -0,0 +1,100 @@
package parser
import (
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
// An inline block comment (code before it on the same line) must not cause the following
// line to be merged onto it — the line's terminator must survive.
func TestStripBlockCommentInlineNoLineMerge(t *testing.T) {
got := StripContent("a := 1 /* note */\nb := 2\n", []StripFlag{StripBlockComments}, protocol.LangGo)
want := "a := 1 \nb := 2\n"
if got.Content != want {
t.Fatalf("inline block comment must not merge lines.\nwant: %q\ngot: %q", want, got.Content)
}
}
// A standalone block-comment line (only whitespace before it) is removed in full,
// including its indentation and terminator — no stray blank/whitespace line left behind.
func TestStripBlockCommentStandaloneRemovesLine(t *testing.T) {
got := StripContent("x\n\t/* c */\ny\n", []StripFlag{StripBlockComments}, protocol.LangGo)
want := "x\ny\n"
if got.Content != want {
t.Fatalf("standalone block comment line must be removed cleanly.\nwant: %q\ngot: %q", want, got.Content)
}
}
// On a CRLF file, removing a standalone block-comment line must consume the full \r\n
// terminator rather than leaving a stray blank (bare-CR) line.
func TestStripBlockCommentCRLFNoStrayBlank(t *testing.T) {
got := StripContent("code\r\n/* c */\r\nmore\r\n", []StripFlag{StripBlockComments}, protocol.LangGo)
want := "code\r\nmore\r\n"
if got.Content != want {
t.Fatalf("CRLF standalone block comment must not leave a stray blank line.\nwant: %q\ngot: %q", want, got.Content)
}
}
// Stripping a hash-style license header must not greedily swallow the blank separator
// line that follows it.
func TestStripLicensePythonPreservesSeparatorBlank(t *testing.T) {
got := StripContent("# Copyright 2024\n# License MIT\n\ncode\n", []StripFlag{StripLicense}, protocol.LangPython)
want := "\ncode\n"
if got.Content != want {
t.Fatalf("python license strip must keep the blank separator.\nwant: %q\ngot: %q", want, got.Content)
}
}
// A multi-line TS named import must be removed in full, not just its first line.
func TestStripTSImportsMultiLine(t *testing.T) {
src := "import {\n a,\n b,\n} from 'x';\nconst y = 1;\n"
got := StripContent(src, []StripFlag{StripImports}, protocol.LangTypeScript)
want := "const y = 1;\n"
if got.Content != want {
t.Fatalf("multi-line import must be fully removed.\nwant: %q\ngot: %q", want, got.Content)
}
}
// A side-effect import without a semicolon must not consume following code.
func TestStripTSImportsSideEffectNoRunaway(t *testing.T) {
src := "import './setup'\nconst y = 1\n"
got := StripContent(src, []StripFlag{StripImports}, protocol.LangTypeScript)
want := "const y = 1\n"
if got.Content != want {
t.Fatalf("side-effect import must not swallow code.\nwant: %q\ngot: %q", want, got.Content)
}
}
// Blank/underscore/dot/aliased single Go imports must all be stripped.
func TestStripGoImportsSingleSpecForms(t *testing.T) {
src := "package main\n\nimport _ \"embed\"\nimport . \"math\"\nimport m \"strings\"\n\nfunc main() {}\n"
got := StripContent(src, []StripFlag{StripImports}, protocol.LangGo)
for _, frag := range []string{`"embed"`, `"math"`, `"strings"`} {
if containsStr(got.Content, frag) {
t.Fatalf("single import %s not stripped, got:\n%s", frag, got.Content)
}
}
if !containsStr(got.Content, "func main") {
t.Fatalf("code removed, got:\n%s", got.Content)
}
}
// A Go // line-comment license after build constraints: the license is removed
// while the build constraint is preserved above it.
func TestStripLicenseGoLineCommentAfterBuildTag(t *testing.T) {
src := "//go:build linux\n\n// Copyright 2024 Acme\n// SPDX-License-Identifier: MIT\n\npackage main\n"
got := StripContent(src, []StripFlag{StripLicense}, protocol.LangGo)
if containsStr(got.Content, "Copyright") {
t.Fatalf("line-comment license not stripped, got:\n%q", got.Content)
}
if !containsStr(got.Content, "//go:build linux") {
t.Fatalf("build constraint must be preserved, got:\n%q", got.Content)
}
if !containsStr(got.Content, "package main") {
t.Fatalf("code removed, got:\n%q", got.Content)
}
}
func containsStr(s, sub string) bool { return strings.Contains(s, sub) }
+195 -51
View File
@@ -1,6 +1,8 @@
package parser package parser
import ( import (
"strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol" "github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
sitter "github.com/smacker/go-tree-sitter" sitter "github.com/smacker/go-tree-sitter"
) )
@@ -35,6 +37,28 @@ func ExtractSymbols(tree *sitter.Tree, content []byte, lang protocol.Language, f
} }
// extractGoSymbols extracts symbols from Go code. // extractGoSymbols extracts symbols from Go code.
// goBodyTypes are the node types whose descendants are function-local (not
// package-level) declarations in Go.
var goBodyTypes = map[string]bool{"block": true, "func_literal": true}
// jsBodyTypes are the node types whose descendants are function-local (not
// module/class-member) declarations in JS/TS.
var jsBodyTypes = map[string]bool{
"statement_block": true, "function_declaration": true, "method_definition": true,
"arrow_function": true, "function_expression": true, "generator_function_declaration": true,
}
// isNestedInBody reports whether n has an ancestor in bodyTypes, i.e. it is a
// function-local declaration rather than a top-level/module one.
func isNestedInBody(n *sitter.Node, bodyTypes map[string]bool) bool {
for p := n.Parent(); p != nil; p = p.Parent() {
if bodyTypes[p.Type()] {
return true
}
}
return false
}
func extractGoSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol { func extractGoSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
var symbols []protocol.Symbol var symbols []protocol.Symbol
@@ -49,6 +73,10 @@ func extractGoSymbols(root *sitter.Node, content []byte, filename string) []prot
case "type_declaration": case "type_declaration":
symbol = extractGoType(n, content, filename) symbol = extractGoType(n, content, filename)
case "const_declaration", "var_declaration": case "const_declaration", "var_declaration":
// Skip function-local var/const; only package-level ones are symbols.
if isNestedInBody(n, goBodyTypes) {
return true
}
syms := extractGoVarConst(n, content, filename) syms := extractGoVarConst(n, content, filename)
symbols = append(symbols, syms...) symbols = append(symbols, syms...)
return true return true
@@ -152,12 +180,20 @@ func extractGoVarConst(n *sitter.Node, content []byte, filename string) []protoc
WalkTree(n, func(node *sitter.Node) bool { WalkTree(n, func(node *sitter.Node) bool {
if node.Type() == "const_spec" || node.Type() == "var_spec" { if node.Type() == "const_spec" || node.Type() == "var_spec" {
nameNode := node.ChildByFieldName("name") // A spec may declare multiple names (e.g. var a, b = 1, 2);
if nameNode != nil { // ChildByFieldName returns only the first, so iterate all "name" fields.
for i := 0; i < int(node.ChildCount()); i++ {
if node.FieldNameForChild(i) != "name" {
continue
}
nameNode := node.Child(i)
if nameNode == nil {
continue
}
symbols = append(symbols, protocol.Symbol{ symbols = append(symbols, protocol.Symbol{
Name: GetNodeText(nameNode, content), Name: GetNodeText(nameNode, content),
Kind: kind, Kind: kind,
Location: NodeLocation(node, filename), Location: NodeLocation(nameNode, filename),
}) })
} }
} }
@@ -182,6 +218,11 @@ func extractJSSymbols(root *sitter.Node, content []byte, filename string) []prot
case "method_definition": case "method_definition":
symbol = extractJSMethod(n, content, filename) symbol = extractJSMethod(n, content, filename)
case "lexical_declaration", "variable_declaration": case "lexical_declaration", "variable_declaration":
// Skip function-local let/const/var; only module/top-level ones
// (including `export const`) are symbols.
if isNestedInBody(n, jsBodyTypes) {
return true
}
syms := extractJSVariable(n, content, filename) syms := extractJSVariable(n, content, filename)
symbols = append(symbols, syms...) symbols = append(symbols, syms...)
return true return true
@@ -386,29 +427,65 @@ func extractCSymbols(root *sitter.Node, content []byte, filename string) []proto
return symbols return symbols
} }
// cDeclaratorName descends the declarator field-chain (pointer/reference/array/
// function declarators) to the innermost name node and returns its text. Unlike a
// first-identifier scan, this returns the function/method NAME (not a parameter)
// and handles C++ method names (field_identifier), out-of-line definitions
// (qualified_identifier like User::foo), destructors and operators.
func cDeclaratorName(declarator *sitter.Node, content []byte) string {
node := declarator
for node != nil {
switch node.Type() {
case "identifier", "field_identifier", "qualified_identifier",
"destructor_name", "operator_name":
return GetNodeText(node, content)
}
next := node.ChildByFieldName("declarator")
if next == nil {
return ""
}
node = next
}
return ""
}
// enclosingCppClassName returns the name of the nearest class/struct ancestor of
// n (for qualifying in-class method names), or "" if n is not inside one.
func enclosingCppClassName(n *sitter.Node, content []byte) string {
for p := n.Parent(); p != nil; p = p.Parent() {
if p.Type() == "class_specifier" || p.Type() == "struct_specifier" {
if name := p.ChildByFieldName("name"); name != nil {
return GetNodeText(name, content)
}
}
}
return ""
}
func extractCFunction(n *sitter.Node, content []byte, filename string) *protocol.Symbol { func extractCFunction(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
declarator := n.ChildByFieldName("declarator") declarator := n.ChildByFieldName("declarator")
if declarator == nil { if declarator == nil {
return nil return nil
} }
// Find the function name within the declarator name := cDeclaratorName(declarator, content)
var name string
WalkTree(declarator, func(node *sitter.Node) bool {
if node.Type() == "identifier" {
name = GetNodeText(node, content)
return false
}
return true
})
if name == "" { if name == "" {
return nil return nil
} }
// In-class definitions are methods; qualify the name with the class. Out-of-line
// definitions already carry a qualified name (Class::method).
kind := protocol.SymbolFunction
if cls := enclosingCppClassName(n, content); cls != "" {
kind = protocol.SymbolMethod
name = cls + "::" + name
} else if strings.Contains(name, "::") {
kind = protocol.SymbolMethod
}
return &protocol.Symbol{ return &protocol.Symbol{
Name: name, Name: name,
Kind: protocol.SymbolFunction, Kind: kind,
Location: NodeLocation(n, filename), Location: NodeLocation(n, filename),
} }
} }
@@ -445,22 +522,22 @@ func extractCFunctionDecl(n *sitter.Node, content []byte, filename string) *prot
return nil return nil
} }
var name string name := cDeclaratorName(declarator, content)
WalkTree(declarator, func(node *sitter.Node) bool {
if node.Type() == "identifier" {
name = GetNodeText(node, content)
return false
}
return true
})
if name == "" { if name == "" {
return nil return nil
} }
kind := protocol.SymbolFunction
if cls := enclosingCppClassName(n, content); cls != "" {
kind = protocol.SymbolMethod
name = cls + "::" + name
} else if strings.Contains(name, "::") {
kind = protocol.SymbolMethod
}
return &protocol.Symbol{ return &protocol.Symbol{
Name: name, Name: name,
Kind: protocol.SymbolFunction, Kind: kind,
Location: NodeLocation(n, filename), Location: NodeLocation(n, filename),
} }
} }
@@ -673,17 +750,52 @@ func extractElixirMacro(n *sitter.Node, content []byte, filename string) *protoc
} }
} }
// extractElixirStruct extracts a struct definition. // extractElixirStruct extracts a struct definition. A defstruct has no name of
// its own — it defines the struct of its enclosing module — so name it after
// that module (%ModuleName{}) rather than the useless literal "defstruct".
func extractElixirStruct(n *sitter.Node, content []byte, filename string) *protocol.Symbol { func extractElixirStruct(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defstruct is typically inside a module, the struct name is the module name name := "defstruct"
// We just mark this as a struct symbol if mod := enclosingElixirModuleName(n, content); mod != "" {
name = "%" + mod + "{}"
}
return &protocol.Symbol{ return &protocol.Symbol{
Name: "defstruct", Name: name,
Kind: protocol.SymbolStruct, Kind: protocol.SymbolStruct,
Location: NodeLocation(n, filename), Location: NodeLocation(n, filename),
} }
} }
// enclosingElixirModuleName walks ancestors of n to the nearest defmodule call
// and returns its module alias (e.g. "MyApp.User"), or "" if none is found.
func enclosingElixirModuleName(n *sitter.Node, content []byte) string {
for p := n.Parent(); p != nil; p = p.Parent() {
if p.Type() != "call" || p.NamedChildCount() < 1 {
continue
}
target := p.NamedChild(0)
if target == nil || GetNodeText(target, content) != "defmodule" {
continue
}
args := p.ChildByFieldName("arguments")
if args == nil && p.NamedChildCount() >= 2 {
args = p.NamedChild(1)
}
if args == nil {
return ""
}
var moduleName string
WalkTree(args, func(node *sitter.Node) bool {
if node.Type() == "alias" {
moduleName = GetNodeText(node, content)
return false
}
return true
})
return moduleName
}
return ""
}
// extractElixirProtocol extracts a protocol definition. // extractElixirProtocol extracts a protocol definition.
func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *protocol.Symbol { func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defprotocol ProtocolName do ... end // defprotocol ProtocolName do ... end
@@ -910,17 +1022,37 @@ func kindMatchesNode(kind protocol.SymbolKind, nodeType string) bool {
return true return true
} }
// FindSymbolRange finds a named symbol in the AST and returns its line range (1-indexed, inclusive). // SymbolCandidate is one symbol matching a name (and optional kind) lookup,
// symbolKind filters by symbol kind (e.g. "function", "struct"); empty string matches any kind. // with its declaration node type and 1-indexed inclusive line range. NameLine
// Returns (0, 0, false) if the symbol is not found. // and NameColumn point at the name identifier itself (1-indexed), which is the
func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) { // position LSP queries (hover/definition/references) expect.
type SymbolCandidate struct {
NodeType string
StartLine int
EndLine int
NameLine int
NameColumn int
}
// symbolDeclNodeTypes is the set of declaration node types FindSymbolCandidates
// treats as nameable symbols across supported languages.
var symbolDeclNodeTypes = map[string]bool{
"function_declaration": true, "method_declaration": true, "type_declaration": true,
"function_definition": true, "class_definition": true, "class_declaration": true,
"interface_declaration": true, "type_alias_declaration": true,
"function_item": true, "struct_item": true, "enum_item": true, "trait_item": true,
"type_item": true, "const_item": true, "mod_item": true,
}
// FindSymbolCandidates returns every declaration whose name equals symbolName and
// whose kind matches symbolKind (empty matches any). Multiple results indicate an
// ambiguous lookup (e.g. two methods named String on different receivers).
func FindSymbolCandidates(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) []SymbolCandidate {
if tree == nil || symbolName == "" { if tree == nil || symbolName == "" {
return return nil
} }
var candidates []SymbolCandidate
WalkTree(tree.RootNode(), func(n *sitter.Node) bool { WalkTree(tree.RootNode(), func(n *sitter.Node) bool {
if found {
return false
}
nameNode := n.ChildByFieldName("name") nameNode := n.ChildByFieldName("name")
if nameNode == nil { if nameNode == nil {
return true return true
@@ -928,22 +1060,34 @@ func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName str
if GetNodeText(nameNode, content) != symbolName { if GetNodeText(nameNode, content) != symbolName {
return true return true
} }
switch n.Type() { if !symbolDeclNodeTypes[n.Type()] {
case "function_declaration", "method_declaration", "type_declaration", return true
"function_definition", "class_definition", "class_declaration",
"interface_declaration", "type_alias_declaration",
"function_item", "struct_item", "enum_item", "trait_item",
"type_item", "const_item", "mod_item":
if !kindMatchesNode(symbolKind, n.Type()) {
return true // kind mismatch, keep searching
}
r := NodeRange(n, filename)
startLine = r.Start.Line
endLine = r.End.Line
found = true
return false
} }
if !kindMatchesNode(symbolKind, n.Type()) {
return true // kind mismatch, keep searching
}
r := NodeRange(n, filename)
nameLoc := NodeLocation(nameNode, filename)
candidates = append(candidates, SymbolCandidate{
NodeType: n.Type(),
StartLine: r.Start.Line,
EndLine: r.End.Line,
NameLine: nameLoc.Line,
NameColumn: nameLoc.Column,
})
return true return true
}) })
return return candidates
}
// FindSymbolRange finds a named symbol in the AST and returns its line range (1-indexed, inclusive).
// symbolKind filters by symbol kind (e.g. "function", "struct"); empty string matches any kind.
// Returns (0, 0, false) if the symbol is not found. On ambiguity it returns the first match;
// callers that need to detect ambiguity should use FindSymbolCandidates.
func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
candidates := FindSymbolCandidates(tree, content, filename, symbolName, symbolKind)
if len(candidates) == 0 {
return 0, 0, false
}
return candidates[0].StartLine, candidates[0].EndLine, true
} }
+201
View File
@@ -7,6 +7,64 @@ import (
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol" "github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
) )
func TestFindSymbolCandidates(t *testing.T) {
r := NewRegistry()
defer r.Close()
// Two methods named String on different receivers, plus a unique func.
content := `package main
type A struct{}
func (a A) String() string { return "a" }
type B struct{}
func (b B) String() string { return "b" }
func Unique() {}
`
ctx := context.Background()
result, err := r.Parse(ctx, "test.go", []byte(content))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
t.Run("ambiguous name returns all candidates", func(t *testing.T) {
got := FindSymbolCandidates(result.Tree, []byte(content), "test.go", "String", "")
if len(got) != 2 {
t.Fatalf("got %d candidates for String, want 2: %+v", len(got), got)
}
if got[0].StartLine >= got[1].StartLine {
t.Errorf("candidates not in source order: %+v", got)
}
})
t.Run("unique name returns one candidate", func(t *testing.T) {
got := FindSymbolCandidates(result.Tree, []byte(content), "test.go", "Unique", "")
if len(got) != 1 {
t.Fatalf("got %d candidates for Unique, want 1: %+v", len(got), got)
}
})
t.Run("missing name returns none", func(t *testing.T) {
got := FindSymbolCandidates(result.Tree, []byte(content), "test.go", "Missing", "")
if len(got) != 0 {
t.Fatalf("got %d candidates for Missing, want 0", len(got))
}
})
t.Run("FindSymbolRange returns first match", func(t *testing.T) {
start, end, found := FindSymbolRange(result.Tree, []byte(content), "test.go", "String", "")
if !found {
t.Fatal("expected String to be found")
}
if start <= 0 || end < start {
t.Errorf("invalid range: start=%d end=%d", start, end)
}
})
}
func TestExtractGoSymbols(t *testing.T) { func TestExtractGoSymbols(t *testing.T) {
r := NewRegistry() r := NewRegistry()
defer r.Close() defer r.Close()
@@ -65,6 +123,149 @@ var globalVar = "test"
} }
} }
// Grouped var/const specs declare multiple names on one spec (var a, b = 1, 2);
// every name must surface as its own symbol, not just the first.
// C++ in-class methods must be extracted as methods qualified by their class
// (Class::method), not misnamed after a parameter or dropped entirely.
func TestExtractCppMethods(t *testing.T) {
r := NewRegistry()
defer r.Close()
content := "class User {\npublic:\n void setName(const char* n) { name_ = n; }\n int id() const { return id_; }\nprivate:\n const char* name_;\n int id_;\n};\n\nvoid freeFunc() {}\n"
result, err := r.Parse(context.Background(), "u.cpp", []byte(content))
if err != nil {
t.Fatalf("parse: %v", err)
}
syms := ExtractSymbols(result.Tree, []byte(content), protocol.LangCpp, "u.cpp")
kindByName := make(map[string]protocol.SymbolKind, len(syms))
for _, s := range syms {
kindByName[s.Name] = s.Kind
}
if kindByName["User::setName"] != protocol.SymbolMethod {
t.Errorf("setName: want method User::setName, got %v", kindByName)
}
if kindByName["User::id"] != protocol.SymbolMethod {
t.Errorf("id: want method User::id, got %v", kindByName)
}
if kindByName["freeFunc"] != protocol.SymbolFunction {
t.Errorf("freeFunc: want function, got %v", kindByName)
}
if _, leaked := kindByName["n"]; leaked {
t.Errorf("parameter 'n' leaked as a symbol: %v", kindByName)
}
}
// Function-local var/const declarations must NOT appear as top-level symbols
// (they flood the list and aren't package/module symbols).
func TestExtractSymbolsExcludesLocals(t *testing.T) {
r := NewRegistry()
defer r.Close()
ctx := context.Background()
t.Run("go", func(t *testing.T) {
content := "package main\n\nvar TopLevel = 1\n\nfunc f() {\n\tvar localVar = 2\n\tconst localConst = 3\n\t_ = localVar\n\t_ = localConst\n}\n"
result, err := r.Parse(ctx, "m.go", []byte(content))
if err != nil {
t.Fatalf("parse: %v", err)
}
names := symbolNameSet(ExtractSymbols(result.Tree, []byte(content), protocol.LangGo, "m.go"))
if !names["TopLevel"] {
t.Errorf("expected TopLevel symbol, got %v", names)
}
if names["localVar"] || names["localConst"] {
t.Errorf("function-local var/const leaked into symbols: %v", names)
}
})
t.Run("js exported and module consts kept, locals dropped", func(t *testing.T) {
content := "export const Exported = 1;\nconst ModuleConst = 2;\nfunction f() {\n const localOne = 3;\n return localOne;\n}\n"
result, err := r.Parse(ctx, "m.js", []byte(content))
if err != nil {
t.Fatalf("parse: %v", err)
}
names := symbolNameSet(ExtractSymbols(result.Tree, []byte(content), protocol.LangJavaScript, "m.js"))
if !names["Exported"] || !names["ModuleConst"] {
t.Errorf("expected Exported and ModuleConst symbols, got %v", names)
}
if names["localOne"] {
t.Errorf("function-local const leaked into symbols: %v", names)
}
})
}
func symbolNameSet(syms []protocol.Symbol) map[string]bool {
m := make(map[string]bool, len(syms))
for _, s := range syms {
m[s.Name] = true
}
return m
}
// An Elixir defstruct must be named after its enclosing module (%Mod{}),
// not the useless literal "defstruct".
func TestExtractElixirStructNamedByModule(t *testing.T) {
r := NewRegistry()
defer r.Close()
content := "defmodule MyApp.User do\n defstruct [:name, :email]\nend\n"
result, err := r.Parse(context.Background(), "user.ex", []byte(content))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangElixir, "user.ex")
var structName string
found := false
for _, s := range symbols {
if s.Kind == protocol.SymbolStruct {
structName = s.Name
found = true
}
}
if !found {
t.Fatalf("no struct symbol extracted, got: %+v", symbols)
}
if structName != "%MyApp.User{}" {
t.Errorf("struct name = %q, want %q", structName, "%MyApp.User{}")
}
}
func TestExtractGoVarConstMultiName(t *testing.T) {
r := NewRegistry()
defer r.Close()
content := `package main
var a, b = 1, 2
const (
C, D = 3, 4
)
`
ctx := context.Background()
result, err := r.Parse(ctx, "test.go", []byte(content))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangGo, "test.go")
want := map[string]protocol.SymbolKind{
"a": protocol.SymbolVariable,
"b": protocol.SymbolVariable,
"C": protocol.SymbolConstant,
"D": protocol.SymbolConstant,
}
got := make(map[string]protocol.SymbolKind)
for _, sym := range symbols {
got[sym.Name] = sym.Kind
}
for name, kind := range want {
if got[name] != kind {
t.Errorf("symbol %q: got kind %q, want %q (symbols: %+v)", name, got[name], kind, symbols)
}
}
}
func TestExtractJSSymbols(t *testing.T) { func TestExtractJSSymbols(t *testing.T) {
r := NewRegistry() r := NewRegistry()
defer r.Close() defer r.Close()
+30
View File
@@ -128,6 +128,33 @@ func TestFormatResultsPaginationCursor(t *testing.T) {
} }
} }
// FormatResultsWithCursor must emit the supplied cursor footer verbatim in place
// of the bare "[remaining: N]" hint when results are truncated.
func TestFormatResultsWithCursor(t *testing.T) {
results := makeResults(5)
cursor := "[cursor: abc123, remaining: 3]"
out := FormatResultsWithCursor(results, 2, "compact", 0, false, cursor)
if !strings.Contains(out, cursor) {
t.Errorf("expected cursor footer %q, got:\n%s", cursor, out)
}
if strings.Contains(out, "[remaining: 3]\n") {
t.Errorf("bare remaining hint should be replaced by cursor footer, got:\n%s", out)
}
// Empty cursorLine falls back to the bare hint (parity with FormatResultsWithOptions).
fallback := FormatResultsWithCursor(results, 2, "compact", 0, false, "")
if !strings.Contains(fallback, "[remaining: 3]") {
t.Errorf("empty cursorLine should fall back to [remaining: 3], got:\n%s", fallback)
}
// No truncation → no footer at all.
none := FormatResultsWithCursor(makeResults(2), 0, "compact", 0, false, cursor)
if strings.Contains(none, cursor) || strings.Contains(none, "[remaining:") {
t.Errorf("no truncation should emit no footer, got:\n%s", none)
}
}
func TestFormatResultsEmpty(t *testing.T) { func TestFormatResultsEmpty(t *testing.T) {
out := FormatResultsWithOptions(nil, 0, "verbose", 0) out := FormatResultsWithOptions(nil, 0, "verbose", 0)
if out != "No matches found." { if out != "No matches found." {
@@ -159,6 +186,9 @@ func TestFirstLineOf(t *testing.T) {
{"line1\nline2", 20, "line1"}, {"line1\nline2", 20, "line1"},
{"\n\nfoo", 20, "foo"}, {"\n\nfoo", 20, "foo"},
{"abcdefghij", 5, "abcd…"}, {"abcdefghij", 5, "abcd…"},
// Multibyte: "ααα" is 6 bytes (each α = 2). maxLen-1 = 3 lands inside the
// 2nd α; must back off to a rune boundary, never split into invalid UTF-8.
{"ααα", 4, "α…"},
} }
for _, c := range cases { for _, c := range cases {
got := firstLineOf(c.input, c.maxLen) got := firstLineOf(c.input, c.maxLen)
+31 -11
View File
@@ -460,7 +460,24 @@ func FormatResults(results []MatchResult, maxResults int) string {
// format: "verbose" (default) | "compact" | "location" // format: "verbose" (default) | "compact" | "location"
// offset: skip this many results before rendering (used for cursor pagination). // offset: skip this many results before rendering (used for cursor pagination).
// verbose: opt-in variadic — pass true to restore "Found N match(es):" preamble (v1 behaviour). // verbose: opt-in variadic — pass true to restore "Found N match(es):" preamble (v1 behaviour).
// When results are truncated it emits a bare "[remaining: N]" hint; callers that
// have a pagination cursor should use FormatResultsWithCursor to emit the full line.
func FormatResultsWithOptions(results []MatchResult, maxResults int, format string, offset int, verbose ...bool) string { func FormatResultsWithOptions(results []MatchResult, maxResults int, format string, offset int, verbose ...bool) string {
return formatResults(results, maxResults, format, offset, len(verbose) > 0 && verbose[0], "")
}
// FormatResultsWithCursor is like FormatResultsWithOptions but, when results are
// truncated, appends cursorLine verbatim instead of the bare "[remaining: N]"
// hint. This lets the caller pass a fully-formed "[cursor: ..., remaining: N]"
// footer without string-replacing a placeholder. If cursorLine is empty it
// behaves like FormatResultsWithOptions.
func FormatResultsWithCursor(results []MatchResult, maxResults int, format string, offset int, verbose bool, cursorLine string) string {
return formatResults(results, maxResults, format, offset, verbose, cursorLine)
}
// formatResults is the shared implementation. cursorLine, when non-empty, is
// emitted in place of the "[remaining: N]" hint on truncation.
func formatResults(results []MatchResult, maxResults int, format string, offset int, verbose bool, cursorLine string) string {
if len(results) == 0 { if len(results) == 0 {
return "No matches found." return "No matches found."
} }
@@ -484,9 +501,8 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
var sb strings.Builder var sb strings.Builder
// Emit preamble only when verbose=true is explicitly passed (opt-in, default off). // Emit preamble only when verbose=true is explicitly passed (opt-in, default off).
wantVerbose := len(verbose) > 0 && verbose[0] if verbose {
if wantVerbose { fmt.Fprintf(&sb, "Found %d match(es):\n", renderCount)
sb.WriteString(fmt.Sprintf("Found %d match(es):\n", renderCount))
} }
switch format { switch format {
@@ -498,13 +514,13 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
nodeType = r.Node.Type() nodeType = r.Node.Type()
} }
firstLine := firstLineOf(r.Text, 80) firstLine := firstLineOf(r.Text, 80)
sb.WriteString(fmt.Sprintf("%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)) fmt.Fprintf(&sb, "%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)
} }
case "location": case "location":
for i := 0; i < renderCount; i++ { for i := 0; i < renderCount; i++ {
r := results[i] r := results[i]
sb.WriteString(fmt.Sprintf("%s:%d\n", r.File, r.Location.Line)) fmt.Fprintf(&sb, "%s:%d\n", r.File, r.Location.Line)
} }
default: // "verbose" default: // "verbose"
@@ -514,7 +530,7 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
if r.Node != nil { if r.Node != nil {
nodeType = r.Node.Type() nodeType = r.Node.Type()
} }
sb.WriteString(fmt.Sprintf("**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType)) fmt.Fprintf(&sb, "**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType)
// Truncate very long text // Truncate very long text
text := r.Text text := r.Text
@@ -538,7 +554,7 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
if len(capText) > 50 { if len(capText) > 50 {
capText = capText[:50] + "..." capText = capText[:50] + "..."
} }
sb.WriteString(fmt.Sprintf("$%s=%s", name, capText)) fmt.Fprintf(&sb, "$%s=%s", name, capText)
} }
sb.WriteString("\n") sb.WriteString("\n")
} }
@@ -547,9 +563,13 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
} }
if remaining > 0 { if remaining > 0 {
// Caller must embed the cursor token; we just append the remaining count hint. if cursorLine != "" {
// The actual [cursor: ...] line is written by the handler after calling MakeCursor. // Caller supplied a fully-formed "[cursor: ..., remaining: N]" footer.
sb.WriteString(fmt.Sprintf("[remaining: %d]\n", remaining)) sb.WriteString(strings.TrimRight(cursorLine, "\n"))
sb.WriteByte('\n')
} else {
fmt.Fprintf(&sb, "[remaining: %d]\n", remaining)
}
} }
return sb.String() return sb.String()
@@ -563,7 +583,7 @@ func firstLineOf(s string, maxLen int) string {
continue continue
} }
if len(line) > maxLen { if len(line) > maxLen {
return line[:maxLen-1] + "…" return line[:util.RuneBoundary(line, maxLen-1)] + "…"
} }
return line return line
} }
+11 -7
View File
@@ -13,6 +13,7 @@ import (
json "github.com/goccy/go-json" json "github.com/goccy/go-json"
"github.com/lukaszraczylo/mcp-filepuff/internal/config" "github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors" "github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol" "github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
) )
@@ -389,13 +390,15 @@ func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatO
if opts.Verbose { if opts.Verbose {
totalMatches := len(results.Results) totalMatches := len(results.Results)
fileCount := len(fileResults) fileCount := len(fileResults)
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount)) fmt.Fprintf(&sb, "Found %d matches in %d files", totalMatches, fileCount)
if results.Truncated { if results.Truncated {
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total)) fmt.Fprintf(&sb, " (truncated, total: %d)", results.Total)
} }
sb.WriteString(":\n\n") sb.WriteString(":\n\n")
} else if results.Truncated { } else if results.Truncated && opts.CursorLine == "" {
sb.WriteString(fmt.Sprintf("(truncated, showing subset of %d total matches)\n\n", results.Total)) // When a cursor footer is present it already reports the remaining count,
// so the prose truncation line would be redundant.
fmt.Fprintf(&sb, "(truncated, showing subset of %d total matches)\n\n", results.Total)
} }
// Write results grouped by file // Write results grouped by file
@@ -408,7 +411,7 @@ func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatO
} }
} }
sb.WriteString(fmt.Sprintf("**%s**\n", relPath)) fmt.Fprintf(&sb, "**%s**\n", relPath)
if opts.Cluster { if opts.Cluster {
writeClusteredResults(&sb, fileResults[file]) writeClusteredResults(&sb, fileResults[file])
@@ -486,10 +489,11 @@ func writeClusteredResults(sb *strings.Builder, results []Result) {
} }
} }
// truncateLine truncates a line if it exceeds maxLen. // truncateLine truncates a line if it exceeds maxLen bytes, backing off to a
// rune boundary so a multibyte character is never split into invalid UTF-8.
func truncateLine(s string, maxLen int) string { func truncateLine(s string, maxLen int) string {
if len(s) <= maxLen { if len(s) <= maxLen {
return s return s
} }
return s[:maxLen-3] + "..." return s[:util.RuneBoundary(s, maxLen-3)] + "..."
} }
+17
View File
@@ -8,10 +8,27 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"unicode/utf8"
"github.com/lukaszraczylo/mcp-filepuff/internal/config" "github.com/lukaszraczylo/mcp-filepuff/internal/config"
) )
// truncateLine must back off to a rune boundary so multibyte characters are
// never split into invalid UTF-8.
func TestTruncateLineRuneSafe(t *testing.T) {
s := strings.Repeat("é", 200) // 2 bytes each → 400 bytes
out := truncateLine(s, 50)
if !utf8.ValidString(out) {
t.Errorf("truncated output is not valid UTF-8: %q", out)
}
if !strings.HasSuffix(out, "...") {
t.Errorf("expected ellipsis suffix, got %q", out)
}
if got := truncateLine("hi", 50); got != "hi" {
t.Errorf("short string should be unchanged, got %q", got)
}
}
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
cfg := config.Default() cfg := config.Default()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+114 -7
View File
@@ -55,16 +55,18 @@ func Epsilon() int { return 2 }
// ---- Feature 1: ast_query format flag ---- // ---- Feature 1: ast_query format flag ----
func TestASTQueryFormatVerboseDefault(t *testing.T) { func TestASTQueryFormatCompactDefault(t *testing.T) {
srv, tmpDir := newFeaturesServer(t) srv, tmpDir := newFeaturesServer(t)
ctx := context.Background() ctx := context.Background()
req := mcp.CallToolRequest{} // Default (no format arg) is compact: one line per match, no code blocks.
req.Params.Arguments = map[string]interface{}{ args := map[string]interface{}{
"pattern": "func $NAME() string", "pattern": "func $NAME() string",
"language": "go", "language": "go",
"paths": []interface{}{tmpDir}, "paths": []interface{}{tmpDir},
} }
req := mcp.CallToolRequest{}
req.Params.Arguments = args
res, err := srv.handleASTQuery(ctx, req) res, err := srv.handleASTQuery(ctx, req)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -72,10 +74,19 @@ func TestASTQueryFormatVerboseDefault(t *testing.T) {
if res == nil || len(res.Content) == 0 { if res == nil || len(res.Content) == 0 {
t.Fatal("nil/empty result") t.Fatal("nil/empty result")
} }
text := res.Content[0].(mcp.TextContent).Text if text := res.Content[0].(mcp.TextContent).Text; strings.Contains(text, "```") {
// verbose mode has code blocks t.Errorf("compact default should NOT have code blocks, got:\n%s", text)
if !strings.Contains(text, "```") { }
t.Errorf("verbose mode should have code blocks, got:\n%s", text)
// Verbose remains available on explicit request.
args["format"] = "verbose"
req.Params.Arguments = args
res, err = srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if text := res.Content[0].(mcp.TextContent).Text; !strings.Contains(text, "```") {
t.Errorf("format=verbose should have code blocks, got:\n%s", text)
} }
} }
@@ -139,6 +150,69 @@ func TestASTQueryFormatLocation(t *testing.T) {
} }
} }
// TestASTQuerySkipsDependencyDirs verifies ast_query excludes node_modules/vendor
// by default (they flood results with third-party code) but still searches them
// when the caller targets one explicitly via paths (the walk root is never skipped).
func TestASTQuerySkipsDependencyDirs(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
deps := map[string]string{
"node_modules": "NodeModulesDep",
"vendor": "VendorDep",
}
for dir, fn := range deps {
full := filepath.Join(tmpDir, dir)
if err := os.MkdirAll(full, 0o750); err != nil {
t.Fatal(err)
}
src := "package dep\n\nfunc " + fn + "() string { return \"\" }\n"
if err := os.WriteFile(filepath.Join(full, "dep.go"), []byte(src), 0o600); err != nil {
t.Fatal(err)
}
}
// Default: workspace-root query must skip the dependency dirs.
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
"format": "location",
}
res, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("default query error: %v", err)
}
// location format prints file:line, so assert on paths.
text := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "a.go") {
t.Errorf("default query should find top-level a.go, got:\n%s", text)
}
for dir := range deps {
if strings.Contains(text, dir+string(filepath.Separator)) {
t.Errorf("default query must skip %s, got:\n%s", dir, text)
}
}
// Explicit target: querying inside node_modules must still find its code.
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{filepath.Join(tmpDir, "node_modules")},
"format": "location",
}
res2, err := srv.handleASTQuery(ctx, req2)
if err != nil {
t.Fatalf("explicit-target query error: %v", err)
}
text2 := res2.Content[0].(mcp.TextContent).Text
if !strings.Contains(text2, "node_modules"+string(filepath.Separator)+"dep.go") {
t.Errorf("explicit node_modules target must find node_modules/dep.go, got:\n%s", text2)
}
}
// ---- Feature 3 (ast_query): pagination cursor ---- // ---- Feature 3 (ast_query): pagination cursor ----
func TestASTQueryPaginationCursor(t *testing.T) { func TestASTQueryPaginationCursor(t *testing.T) {
@@ -281,6 +355,39 @@ func TestFileSearchCursorStaleHash(t *testing.T) {
} }
} }
// TestFileSearchFirstPageCursor is a strict regression guard: when the first
// page is capped by max_results but more matches exist, the response MUST carry
// a cursor so the caller can page. Previously offset==0 emitted "(truncated...)"
// with no cursor, silently breaking pagination on the common path.
func TestFileSearchFirstPageCursor(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
if srv.searcher == nil {
t.Skip("rg not available")
}
ctx := context.Background()
// Corpus has 5 lines containing "func"; cap at 1 forces truncation on page 1.
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
"max_results": float64(1),
"context_lines": float64(0),
}
res, err := srv.handleFileSearch(ctx, req)
if err != nil {
t.Fatalf("file_search error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "[cursor:") {
t.Errorf("first page truncated at max_results=1 with 5 matches must emit a cursor, got:\n%s", text)
}
// The dead-end prose must not be used when a cursor is available.
if strings.Contains(text, "(truncated, showing subset") {
t.Errorf("dead-end truncation prose should be replaced by a cursor footer, got:\n%s", text)
}
}
func TestFileSearchPaginationCursor(t *testing.T) { func TestFileSearchPaginationCursor(t *testing.T) {
srv, tmpDir := newFeaturesServer(t) srv, tmpDir := newFeaturesServer(t)
if srv.searcher == nil { if srv.searcher == nil {
+31 -17
View File
@@ -67,7 +67,10 @@ func (s *Server) resolveASTQueryParams(request mcp.CallToolRequest) (*astQueryPa
if prefsFormat != "" { if prefsFormat != "" {
p.format = prefsFormat p.format = prefsFormat
} else { } else {
p.format = "verbose" // Default to compact (one line per match): far fewer tokens than
// verbose (full code + captures) on multi-match queries. Callers can
// opt into verbose per-call or via the default_format session pref.
p.format = "compact"
} }
} }
@@ -128,7 +131,10 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
return nil return nil
} }
if info.IsDir() { if info.IsDir() {
if strings.HasPrefix(info.Name(), ".") { // Skip hidden and dependency dirs (third-party code that would
// flood results), but never the walk root itself — the caller
// explicitly targeted it, e.g. paths:["vendor/foo"].
if path != searchPath && (strings.HasPrefix(info.Name(), ".") || isDependencyDir(info.Name())) {
return filepath.SkipDir return filepath.SkipDir
} }
return nil return nil
@@ -137,11 +143,13 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
return nil return nil
} }
content, err := os.ReadFile(path) // Skip oversized files before reading them off disk.
if err != nil { if info.Size() > s.cfg.MaxFileSize {
return nil return nil
} }
if int64(len(content)) > s.cfg.MaxFileSize {
content, err := os.ReadFile(path)
if err != nil {
return nil return nil
} }
@@ -167,6 +175,19 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
return allResults return allResults
} }
// isDependencyDir reports whether name is a well-known third-party dependency
// directory that ast_query skips by default. These hold vendored/installed code,
// not the caller's source, and parsing them wastes time and floods results with
// irrelevant matches. Target one explicitly via paths to query inside it.
func isDependencyDir(name string) bool {
switch name {
case "node_modules", "vendor", "bower_components":
return true
default:
return false
}
}
// hasAnySuffix reports whether path ends with any of the given suffixes. // hasAnySuffix reports whether path ends with any of the given suffixes.
func hasAnySuffix(path string, suffixes []string) bool { func hasAnySuffix(path string, suffixes []string) bool {
for _, ext := range suffixes { for _, ext := range suffixes {
@@ -222,16 +243,9 @@ func (s *Server) handleASTQuery(ctx context.Context, request mcp.CallToolRequest
allResults := s.runASTQueryWalk(ctx, p, exts) allResults := s.runASTQueryWalk(ctx, p, exts)
cursorFooter := buildASTCursorFooter(len(allResults), p.offset, p.maxResults, p.queryHash) cursorFooter := buildASTCursorFooter(len(allResults), p.offset, p.maxResults, p.queryHash)
output := query.FormatResultsWithOptions(allResults, p.maxResults, p.format, p.offset, p.verbose) // The formatter emits cursorFooter in place of its "[remaining: N]" hint when
if cursorFooter != "" { // results are truncated; no fragile placeholder string-replacement needed.
// Replace the [remaining: N] placeholder emitted by FormatResultsWithOptions output := query.FormatResultsWithCursor(allResults, p.maxResults, p.format, p.offset, p.verbose, cursorFooter)
// with the full [cursor: ..., remaining: N] line.
output = strings.ReplaceAll(output, fmt.Sprintf("[remaining: %d]\n", len(allResults)-p.offset-p.maxResults), cursorFooter+"\n")
// Fallback: if placeholder wasn't present (e.g. format=location), append footer.
if !strings.Contains(output, cursorFooter) {
output = strings.TrimRight(output, "\n") + "\n" + cursorFooter + "\n"
}
}
return mcp.NewToolResultText(output), nil return mcp.NewToolResultText(output), nil
} }
@@ -260,12 +274,12 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
} }
} }
sb.WriteString(fmt.Sprintf("**%s** (%d lines, %s)\n\n", relPath, len(splitLines(string(content))), lang)) fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", relPath, countLines(content), lang)
sb.WriteString("Symbols:\n") sb.WriteString("Symbols:\n")
for _, sym := range symbols { for _, sym := range symbols {
kindStr := symbolKindIcon(sym.Kind) kindStr := symbolKindIcon(sym.Kind)
sb.WriteString(fmt.Sprintf(" %s %s L%d\n", kindStr, sym.Name, sym.Location.Line)) fmt.Fprintf(&sb, " %s %s L%d\n", kindStr, sym.Name, sym.Location.Line)
} }
return sb.String() return sb.String()
+18 -19
View File
@@ -11,16 +11,6 @@ import (
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
) )
// unescapeNewlines converts literal \n, \t, \" sequences to actual characters.
// This handles cases where MCP clients send double-escaped JSON strings.
func unescapeNewlines(s string) string {
s = strings.ReplaceAll(s, "\\n", "\n")
s = strings.ReplaceAll(s, "\\t", "\t")
s = strings.ReplaceAll(s, "\\\"", "\"")
s = strings.ReplaceAll(s, "\\\\", "\\")
return s
}
// handleEditApply handles the edit_apply tool. // handleEditApply handles the edit_apply tool.
func (s *Server) handleEditApply(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { func (s *Server) handleEditApply(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return s.handleEdit(ctx, request) return s.handleEdit(ctx, request)
@@ -51,8 +41,12 @@ func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*
return mcp.NewToolResultError("file is outside workspace root"), nil return mcp.NewToolResultError("file is outside workspace root"), nil
} }
// new_content arrives already fully decoded by the JSON-RPC layer (mcp-go):
// JSON escapes such as \n, \t, \\, \" have been resolved to their real bytes.
// It must therefore be used verbatim — any further unescaping would corrupt
// legitimate backslash sequences in source code (string literals, regexes,
// Windows paths). See TestEditApplyPreservesBackslashSequences*.
newContent := request.GetString("new_content", "") newContent := request.GetString("new_content", "")
newContent = unescapeNewlines(newContent)
selectorName := request.GetString("selector_name", "") selectorName := request.GetString("selector_name", "")
@@ -114,21 +108,26 @@ func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*
} }
} }
// countDiffLines counts added (+) and removed (-) lines in a unified diff string. // countDiffLines counts added (+) and removed (-) content lines in a unified diff.
// Only lines inside hunks are counted (everything after the first "@@" header), so the
// "---"/"+++" file headers are skipped structurally — and content whose own text starts
// with + or - is counted correctly rather than mistaken for a header. The git-style
// "\ No newline at end of file" marker is ignored.
func countDiffLines(diff string) (added, removed int) { func countDiffLines(diff string) (added, removed int) {
inHunk := false
for _, line := range strings.Split(diff, "\n") { for _, line := range strings.Split(diff, "\n") {
if len(line) == 0 { if strings.HasPrefix(line, "@@") {
inHunk = true
continue
}
if !inHunk || line == "" {
continue continue
} }
switch line[0] { switch line[0] {
case '+': case '+':
if !strings.HasPrefix(line, "+++") { added++
added++
}
case '-': case '-':
if !strings.HasPrefix(line, "---") { removed++
removed++
}
} }
} }
return return
+95 -39
View File
@@ -1,47 +1,103 @@
package server package server
import "testing" import (
"context"
"os"
"strings"
"testing"
func TestUnescapeNewlines(t *testing.T) { "github.com/mark3labs/mcp-go/mcp"
tests := []struct { )
name string
input string
expected string
}{
{
name: "no escapes",
input: "func test() {\n\treturn true;\n}",
expected: "func test() {\n\treturn true;\n}",
},
{
name: "literal backslash n",
input: "func test() {\\n\\treturn true;\\n}",
expected: "func test() {\n\treturn true;\n}",
},
{
name: "literal backslash t",
input: "func test() {\\n\\treturn true;\\n}",
expected: "func test() {\n\treturn true;\n}",
},
{
name: "literal quotes",
input: `func test() {\n\treturn \"true\";\n}`,
expected: "func test() {\n\treturn \"true\";\n}",
},
{ // callEdit invokes handleEditApply with the given args and returns the result.
name: "mixed content", func callEdit(t *testing.T, srv *Server, args map[string]any) *mcp.CallToolResult {
input: "line1\\nline2\\tindented\\nline3", t.Helper()
expected: "line1\nline2\tindented\nline3", req := mcp.CallToolRequest{}
}, req.Params.Arguments = args
res, err := srv.handleEditApply(context.Background(), req)
if err != nil {
t.Fatalf("handleEditApply error: %v", err)
}
if res == nil {
t.Fatal("handleEditApply returned nil result")
}
return res
}
// TestEditApplyPreservesBackslashSequencesText is the primary regression guard for the
// double-unescape bug: new_content arrives from the JSON-RPC layer already fully decoded,
// so the handler must write it to disk verbatim. Literal backslash sequences (\n, \t, \",
// \\) that legitimately appear in source code must survive byte-for-byte.
func TestEditApplyPreservesBackslashSequencesText(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "note.txt", "PLACEHOLDER\n")
// Exactly the bytes a client intends after JSON decoding: backslash sequences are
// real source text here, NOT escapes to be interpreted.
newContent := `printf("a\nb\tc"); re = \d+; path = "C:\\tmp"; q = \"`
res := callEdit(t, srv, map[string]any{
"file": f,
"operation": "replace",
"selector_text": "PLACEHOLDER",
"new_content": newContent,
"response": "none",
})
if res.IsError {
t.Fatalf("edit returned error: %+v", res.Content)
} }
for _, tt := range tests { got, err := os.ReadFile(f)
t.Run(tt.name, func(t *testing.T) { if err != nil {
result := unescapeNewlines(tt.input) t.Fatalf("read back: %v", err)
if result != tt.expected { }
t.Errorf("unescapeNewlines(%q) = %q, expected %q", tt.input, result, tt.expected) want := newContent + "\n"
} if string(got) != want {
}) t.Fatalf("new_content not written verbatim (double-unescape regression).\nwant: %q\ngot: %q", want, string(got))
}
}
// TestCountDiffLinesHunkAware verifies the line counter skips file headers structurally and
// counts content lines whose own text begins with + or - (previously dropped as "+++"/"---").
func TestCountDiffLinesHunkAware(t *testing.T) {
diff := "--- a.txt\n+++ a.txt\n@@ -1,3 +1,3 @@\n context\n+++plusprefix\n+normal add\n---minusprefix\n-normal del\n"
added, removed := countDiffLines(diff)
if added != 2 || removed != 2 {
t.Fatalf("hunk-aware diff count wrong: added=%d removed=%d (want 2 and 2)", added, removed)
}
}
// TestEditApplyPreservesBackslashSequencesCode covers the AST/code path: the same verbatim
// guarantee must hold for syntactically-validated code files.
func TestEditApplyPreservesBackslashSequencesCode(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
src := "package main\n\nfunc demo() {\n\tprintln(\"old\")\n}\n"
f := writeFile(t, tmpDir, "demo.go", src)
// Real tab indentation + literal backslash sequences inside string/raw-string literals.
newBody := "func demo() {\n\tprintln(\"a\\nb\\tc\")\n\t_ = `\\d+`\n\t_ = \"C:\\\\tmp\"\n}"
res := callEdit(t, srv, map[string]any{
"file": f,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "demo",
"new_content": newBody,
"response": "none",
})
if res.IsError {
t.Fatalf("edit returned error: %+v", res.Content)
}
got, err := os.ReadFile(f)
if err != nil {
t.Fatalf("read back: %v", err)
}
if !strings.Contains(string(got), newBody) {
t.Fatalf("backslash sequences corrupted in code edit.\nwant substring:\n%q\ngot file:\n%q", newBody, string(got))
} }
} }
+114 -22
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"bufio" "bufio"
"bytes"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -15,10 +16,19 @@ import (
"github.com/lukaszraczylo/mcp-filepuff/internal/parser" "github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/internal/search" "github.com/lukaszraczylo/mcp-filepuff/internal/search"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors" "github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/fuzzy"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol" "github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
) )
// Symbol-suggestion tuning for not-found symbol_name reads.
const (
// maxSymbolSuggestionDistance is the max fuzzy edit distance for a suggestion.
maxSymbolSuggestionDistance = 3
// maxSymbolSuggestions caps how many "did you mean" names are listed.
maxSymbolSuggestions = 3
)
// handleFileSearch handles the file_search tool. // handleFileSearch handles the file_search tool.
func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
start := time.Now() start := time.Now()
@@ -51,7 +61,10 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
prefsMaxResults = prefs.DefaultMaxResults prefsMaxResults = prefs.DefaultMaxResults
prefsCluster = prefs.DefaultCluster prefsCluster = prefs.DefaultCluster
} }
maxResults := effectiveInt(request, "max_results", prefsMaxResults, 0) // Default to the configured cap (not 0/unbounded) so a broad pattern can't
// flood memory and the context window; an explicit max_results still wins,
// and the cursor footer lets the caller page through the rest.
maxResults := effectiveInt(request, "max_results", prefsMaxResults, s.cfg.MaxSearchResults)
cluster := effectiveBool(request, "cluster", prefsCluster, false) cluster := effectiveBool(request, "cluster", prefsCluster, false)
cursorStr := request.GetString("cursor", "") cursorStr := request.GetString("cursor", "")
@@ -87,13 +100,14 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
} }
req := &search.Request{ req := &search.Request{
Pattern: pattern, Pattern: pattern,
Paths: paths, Paths: paths,
FileTypes: fileTypes, FileTypes: fileTypes,
IgnoreCase: ignoreCase, IgnoreCase: ignoreCase,
Regex: regex, Regex: regex,
ContextLines: contextLines, ContextLines: contextLines,
MaxResults: rgMaxResults, MaxResults: rgMaxResults,
IncludeHidden: request.GetBool("include_hidden", false),
} }
results, err := s.searcher.Search(ctx, req) results, err := s.searcher.Search(ctx, req)
@@ -113,13 +127,23 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
// Apply in-process max_results cap and compute cursor footer. // Apply in-process max_results cap and compute cursor footer.
var cursorLine string var cursorLine string
if maxResults > 0 && len(results.Results) > maxResults { switch {
case maxResults > 0 && len(results.Results) > maxResults:
// Cursor pages (offset>0) fetch all results, so cap here and emit a cursor.
remaining := len(results.Results) - maxResults remaining := len(results.Results) - maxResults
results.Results = results.Results[:maxResults] results.Results = results.Results[:maxResults]
results.Truncated = true results.Truncated = true
nextOffset := offset + maxResults nextOffset := offset + maxResults
nextCursor := cursor.Encode(nextOffset, queryHash) nextCursor := cursor.Encode(nextOffset, queryHash)
cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining) cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
case offset == 0 && maxResults > 0 && results.Truncated:
// First page: ripgrep capped us at exactly maxResults in parseOutput, but
// more matches exist. Emit a cursor so the caller can page instead of being
// told "truncated" with no way to continue. remaining derives from rg's
// total match count (a submatch count — an upper-bound proxy for rows).
nextCursor := cursor.Encode(maxResults, queryHash)
remaining := max(results.Total-maxResults, 1)
cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
} }
s.logger.Info("search completed", s.logger.Info("search completed",
@@ -184,7 +208,7 @@ func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest
} }
result, err := s.readOneFile(ctx, request, p) result, err := s.readOneFile(ctx, request, p)
if err != nil { if err != nil {
output.WriteString(fmt.Sprintf("--- %s ---\n[error: %s]\n", p, errors.SanitizeError(err))) fmt.Fprintf(&output, "--- %s ---\n[error: %s]\n", p, errors.SanitizeError(err))
continue continue
} }
// Extract etag from result footer for dedup check. // Extract etag from result footer for dedup check.
@@ -192,12 +216,12 @@ func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest
if etag != "" { if etag != "" {
if firstPath, seen := seenEtag[etag]; seen { if firstPath, seen := seenEtag[etag]; seen {
// Duplicate content: emit pointer instead of full content. // Duplicate content: emit pointer instead of full content.
output.WriteString(fmt.Sprintf("--- %s ---\n[duplicate of %s, etag: %s]\n", p, firstPath, etag)) fmt.Fprintf(&output, "--- %s ---\n[duplicate of %s, etag: %s]\n", p, firstPath, etag)
continue continue
} }
seenEtag[etag] = p seenEtag[etag] = p
} }
output.WriteString(fmt.Sprintf("--- %s ---\n%s", p, result)) fmt.Fprintf(&output, "--- %s ---\n%s", p, result)
} }
return mcp.NewToolResultText(output.String()), nil return mcp.NewToolResultText(output.String()), nil
} }
@@ -439,12 +463,19 @@ func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, p
// Symbol-based line range: find the symbol and use its exact bounds. // Symbol-based line range: find the symbol and use its exact bounds.
if symbolName != "" { if symbolName != "" {
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", "")) symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
start, end, found := s.resolveSymbolLines(ctx, path, content, symbolName, symbolKind) candidates := s.resolveSymbolCandidates(ctx, path, content, symbolName, symbolKind)
if !found { switch len(candidates) {
return "", fmt.Errorf("symbol %q not found in %s", symbolName, path) case 0:
return "", fmt.Errorf("symbol %q not found in %s%s", symbolName, path, s.nearestSymbolSuggestion(ctx, path, content, symbolName))
case 1:
lineStart = candidates[0].StartLine
lineEnd = candidates[0].EndLine
default:
// Ambiguous: returning the first match risks reading the wrong
// symbol. List candidates so the caller can disambiguate.
return "", fmt.Errorf("symbol %q is ambiguous in %s (%d matches): %s — disambiguate with symbol_kind, or read a specific one with line_start/line_end",
symbolName, path, len(candidates), formatSymbolCandidates(candidates))
} }
lineStart = start
lineEnd = end
} }
// Clamp to valid range. // Clamp to valid range.
@@ -499,14 +530,60 @@ func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, p
return output.String(), nil return output.String(), nil
} }
// resolveSymbolLines parses the AST and returns the line range of the named symbol. // resolveSymbolCandidates parses the AST and returns every symbol matching the
// symbolKind optionally filters by kind (empty = any). // name (and optional kind). Zero results means not found; more than one means an
func (s *Server) resolveSymbolLines(ctx context.Context, path string, content []byte, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) { // ambiguous lookup. Returns nil on parse failure (treated as not found).
func (s *Server) resolveSymbolCandidates(ctx context.Context, path string, content []byte, symbolName string, symbolKind protocol.SymbolKind) []parser.SymbolCandidate {
result, err := s.parser.Parse(ctx, path, content) result, err := s.parser.Parse(ctx, path, content)
if err != nil { if err != nil {
return return nil
} }
return parser.FindSymbolRange(result.Tree, content, path, symbolName, symbolKind) return parser.FindSymbolCandidates(result.Tree, content, path, symbolName, symbolKind)
}
// nearestSymbolSuggestion returns a " — did you mean: a, b, c?" hint listing the
// closest symbol names in the file to name, or "" if none are reasonably close.
// The re-parse is cheap: the tree is served from the parser cache.
func (s *Server) nearestSymbolSuggestion(ctx context.Context, path string, content []byte, name string) string {
result, err := s.parser.Parse(ctx, path, content)
if err != nil {
return ""
}
syms := parser.ExtractSymbols(result.Tree, content, result.Language, path)
if len(syms) == 0 {
return ""
}
names := make([]string, 0, len(syms))
seen := make(map[string]bool, len(syms))
for _, sym := range syms {
if !seen[sym.Name] {
seen[sym.Name] = true
names = append(names, sym.Name)
}
}
matches := fuzzy.New(maxSymbolSuggestionDistance).Match(name, names)
if len(matches) == 0 {
return ""
}
top := matches
if len(top) > maxSymbolSuggestions {
top = top[:maxSymbolSuggestions]
}
suggestions := make([]string, len(top))
for i, m := range top {
suggestions[i] = m.Text
}
return " — did you mean: " + strings.Join(suggestions, ", ") + "?"
}
// formatSymbolCandidates renders ambiguous symbol matches as a compact,
// comma-separated list, e.g. "method_declaration L10-24, method_declaration L42-58".
func formatSymbolCandidates(candidates []parser.SymbolCandidate) string {
parts := make([]string, len(candidates))
for i, c := range candidates {
parts[i] = fmt.Sprintf("%s L%d-%d", c.NodeType, c.StartLine, c.EndLine)
}
return strings.Join(parts, ", ")
} }
// writeLines writes the selected line range into output, applying all formatting options. // writeLines writes the selected line range into output, applying all formatting options.
@@ -571,6 +648,21 @@ func extractEtag(result string) string {
// splitLines splits a string into lines. // splitLines splits a string into lines.
// For large files (> 1MB), uses bufio.Scanner which is more memory efficient. // For large files (> 1MB), uses bufio.Scanner which is more memory efficient.
// For smaller files, uses simple string split which is faster. // For smaller files, uses simple string split which is faster.
// countLines returns the number of source lines in content. Unlike
// len(splitLines(...)), it does not over-count newline-terminated files
// (a trailing "\n" does not introduce a phantom final line) and avoids
// materializing the line slice.
func countLines(content []byte) int {
if len(content) == 0 {
return 0
}
n := bytes.Count(content, []byte{'\n'})
if content[len(content)-1] != '\n' {
n++
}
return n
}
func splitLines(s string) []string { func splitLines(s string) []string {
const largeSizeThreshold = 1024 * 1024 // 1MB const largeSizeThreshold = 1024 * 1024 // 1MB
+22
View File
@@ -266,6 +266,28 @@ struct Foo {
// ---- Feature 2: strip flag ---- // ---- Feature 2: strip flag ----
// A misspelled symbol_name should fail with a "did you mean" hint listing the
// closest real symbols, so the model can self-correct.
func TestReadSymbolNotFoundSuggestsNearest(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\n\nfunc Hello() {}\n\nfunc Goodbye() {}\n"
f := writeFile(t, tmpDir, "main.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"symbol_name": "Helo", // typo for Hello
})
if !strings.Contains(out, "not found") {
t.Errorf("expected not-found error, got:\n%s", out)
}
if !strings.Contains(out, "did you mean") || !strings.Contains(out, "Hello") {
t.Errorf("expected suggestion of Hello, got:\n%s", out)
}
}
func TestStripImportsGo(t *testing.T) { func TestStripImportsGo(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir) srv := newTestServer(t, tmpDir)
+59 -19
View File
@@ -10,6 +10,7 @@ import (
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp" "github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser" "github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors" "github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
) )
@@ -26,20 +27,30 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
return mcp.NewToolResultError("file is required"), nil return mcp.NewToolResultError("file is required"), nil
} }
line := request.GetInt("line", 0)
if line <= 0 {
return mcp.NewToolResultError("line must be positive"), nil
}
col := request.GetInt("column", 0)
if col <= 0 {
return mcp.NewToolResultError("column must be positive"), nil
}
if !s.cfg.IsPathAllowed(file) { if !s.cfg.IsPathAllowed(file) {
return mcp.NewToolResultError("file is outside workspace root"), nil return mcp.NewToolResultError("file is outside workspace root"), nil
} }
// Position can be given directly (line+column) or resolved from a symbol name.
var line, col int
if symbolName := request.GetString("symbol_name", ""); symbolName != "" {
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
resolvedLine, resolvedCol, errResult := s.resolveLSPSymbolPosition(ctx, file, symbolName, symbolKind)
if errResult != nil {
return errResult, nil
}
line, col = resolvedLine, resolvedCol
} else {
line = request.GetInt("line", 0)
if line <= 0 {
return mcp.NewToolResultError("line must be positive (or provide symbol_name)"), nil
}
col = request.GetInt("column", 0)
if col <= 0 {
return mcp.NewToolResultError("column must be positive (or provide symbol_name)"), nil
}
}
verbose := request.GetBool("verbose", false) verbose := request.GetBool("verbose", false)
switch action { switch action {
@@ -76,6 +87,31 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
} }
} }
// resolveLSPSymbolPosition resolves a symbol name to the 1-indexed line:column of
// its name identifier, for use as an LSP position. Returns an error result when the
// file can't be read/parsed, the symbol is not found (with a "did you mean" hint),
// or the name is ambiguous (listing candidates).
func (s *Server) resolveLSPSymbolPosition(ctx context.Context, file, symbolName string, symbolKind protocol.SymbolKind) (line, col int, errResult *mcp.CallToolResult) {
content, err := os.ReadFile(file)
if err != nil {
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to read file: %s", errors.SanitizeError(err)))
}
result, err := s.parser.Parse(ctx, file, content)
if err != nil {
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to parse file: %s", errors.SanitizeError(err)))
}
candidates := parser.FindSymbolCandidates(result.Tree, content, file, symbolName, symbolKind)
switch len(candidates) {
case 0:
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q not found in %s%s", symbolName, file, s.nearestSymbolSuggestion(ctx, file, content, symbolName)))
case 1:
return candidates[0].NameLine, candidates[0].NameColumn, nil
default:
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q is ambiguous in %s (%d matches): %s — disambiguate with symbol_kind, or pass line+column",
symbolName, file, len(candidates), formatSymbolCandidates(candidates)))
}
}
// lspHover performs hover (symbol info) for the given position. // lspHover performs hover (symbol info) for the given position.
func (s *Server) lspHover(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) { func (s *Server) lspHover(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
hover, err := s.lspManager.Hover(ctx, file, line, col) hover, err := s.lspManager.Hover(ctx, file, line, col)
@@ -115,10 +151,14 @@ func (s *Server) handleSymbolAtFallback(ctx context.Context, file string, line,
var output strings.Builder var output strings.Builder
if verbose { if verbose {
output.WriteString("**Symbol Information** (AST fallback)\n\n") output.WriteString("**Symbol Information** (AST fallback — LSP unavailable)\n\n")
} else {
// Always signal the degraded result so the model knows this is an AST
// guess, not real LSP type info, and can decide whether to retry.
output.WriteString("[AST fallback — LSP unavailable]\n")
} }
output.WriteString(fmt.Sprintf("Node type: `%s`\n", node.Type())) fmt.Fprintf(&output, "Node type: `%s`\n", node.Type())
output.WriteString(fmt.Sprintf("Text: `%s`\n", parser.GetNodeText(node, content))) fmt.Fprintf(&output, "Text: `%s`\n", parser.GetNodeText(node, content))
return mcp.NewToolResultText(output.String()), nil return mcp.NewToolResultText(output.String()), nil
} }
@@ -136,12 +176,12 @@ func (s *Server) lspDefinition(ctx context.Context, file string, line, col int,
var output strings.Builder var output strings.Builder
if verbose { if verbose {
output.WriteString(fmt.Sprintf("Found %d definition(s):\n\n", len(locations))) fmt.Fprintf(&output, "Found %d definition(s):\n\n", len(locations))
} }
for _, loc := range locations { for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI) filePath := lsp.URIToFile(loc.URI)
output.WriteString(fmt.Sprintf("**%s:%d:%d**\n", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1)) fmt.Fprintf(&output, "**%s:%d:%d**\n", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1)
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3) preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
if preview != "" { if preview != "" {
@@ -187,7 +227,7 @@ func (s *Server) lspReferences(ctx context.Context, file string, line, col int,
func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string, total int, compact bool, verbose bool) string { func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string, total int, compact bool, verbose bool) string {
var output strings.Builder var output strings.Builder
if verbose { if verbose {
output.WriteString(fmt.Sprintf("Found %d reference(s):\n\n", total)) fmt.Fprintf(&output, "Found %d reference(s):\n\n", total)
} }
for _, filePath := range fileOrder { for _, filePath := range fileOrder {
@@ -195,9 +235,9 @@ func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string,
if compact { if compact {
output.WriteString(formatReferencesCompact(filePath, locs)) output.WriteString(formatReferencesCompact(filePath, locs))
} else { } else {
output.WriteString(fmt.Sprintf("**%s** (%d)\n", filePath, len(locs))) fmt.Fprintf(&output, "**%s** (%d)\n", filePath, len(locs))
for _, loc := range locs { for _, loc := range locs {
output.WriteString(fmt.Sprintf(" L%d:%d\n", loc.Range.Start.Line+1, loc.Range.Start.Character+1)) fmt.Fprintf(&output, " L%d:%d\n", loc.Range.Start.Line+1, loc.Range.Start.Character+1)
} }
output.WriteString("\n") output.WriteString("\n")
} }
@@ -273,7 +313,7 @@ func (s *Server) readFilePreview(file string, line, contextLines int) string {
if i+1 == line { if i+1 == line {
prefix = "> " prefix = "> "
} }
preview.WriteString(fmt.Sprintf("%s%4d: %s\n", prefix, i+1, lineText)) fmt.Fprintf(&preview, "%s%4d: %s\n", prefix, i+1, lineText)
} }
return preview.String() return preview.String()
+63
View File
@@ -1,12 +1,25 @@
package server package server
import ( import (
"context"
"strings" "strings"
"testing" "testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp" "github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
"github.com/mark3labs/mcp-go/mcp"
) )
// errorText extracts the text payload from an error CallToolResult.
func errorText(res *mcp.CallToolResult) string {
if res == nil || len(res.Content) == 0 {
return ""
}
if tc, ok := res.Content[0].(mcp.TextContent); ok {
return tc.Text
}
return ""
}
// makeLocation builds an lsp.Location with a file:// URI. // makeLocation builds an lsp.Location with a file:// URI.
func makeLocation(file string, line, col int) lsp.Location { func makeLocation(file string, line, col int) lsp.Location {
return lsp.Location{ return lsp.Location{
@@ -179,3 +192,53 @@ func TestFormatReferencesVerbosePreservesLPrefix(t *testing.T) {
t.Errorf("verbose output must contain L3:4, got:\n%s", out) t.Errorf("verbose output must contain L3:4, got:\n%s", out)
} }
} }
// resolveLSPSymbolPosition underpins lsp_query's symbol_name affordance: it maps
// a symbol name to the line:column of its name identifier, or returns an error
// result for not-found / ambiguous names. It does not require a running LSP.
func TestResolveLSPSymbolPosition(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\n" + // 1
"\n" + // 2
"type A struct{}\n" + // 3
"func (a A) Run() {}\n" + // 4
"\n" + // 5
"type B struct{}\n" + // 6
"func (b B) Run() {}\n" + // 7
"\n" + // 8
"func Unique() {}\n" // 9
f := writeFile(t, tmpDir, "main.go", goSrc)
ctx := context.Background()
t.Run("unique symbol resolves to its name position", func(t *testing.T) {
line, col, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Unique", "")
if errRes != nil {
t.Fatalf("unexpected error result for Unique: %s", errorText(errRes))
}
if line != 9 || col != 6 {
t.Errorf("Unique position = %d:%d, want 9:6", line, col)
}
})
t.Run("ambiguous symbol errors", func(t *testing.T) {
_, _, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Run", "")
if errRes == nil {
t.Fatal("expected ambiguity error for Run")
}
if !strings.Contains(errorText(errRes), "ambiguous") {
t.Errorf("expected 'ambiguous' in error, got: %s", errorText(errRes))
}
})
t.Run("missing symbol errors", func(t *testing.T) {
_, _, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Nope", "")
if errRes == nil {
t.Fatal("expected not-found error for Nope")
}
if !strings.Contains(errorText(errRes), "not found") {
t.Errorf("expected 'not found' in error, got: %s", errorText(errRes))
}
})
}
+23 -2
View File
@@ -75,7 +75,7 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" +
"| Flag | Effect |\n" + "| Flag | Effect |\n" +
"|------|--------|\n" + "|------|--------|\n" +
"| `verbose=true` | Emit `Found N match(es):` preamble (v1 behaviour). Default: false. |\n" + "| `verbose=true` | Emit `Found N match(es):` preamble (v1 behaviour). Default: false. |\n" +
"| `format` | `verbose` (default, full code+captures) \\| `compact` (one line per match) \\| `location` (file:line only) |\n" + "| `format` | `compact` (default, one line per match) \\| `verbose` (full code+captures) \\| `location` (file:line only) |\n" +
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" + "| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
"| `max_results` | Page size (default: 100). |\n" + "| `max_results` | Page size (default: 100). |\n" +
"| `name_exact` | Exact symbol name to match. |\n" + "| `name_exact` | Exact symbol name to match. |\n" +
@@ -88,9 +88,22 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" +
"| `$NAME` | Matches a single node, captures as `$NAME` |\n" + "| `$NAME` | Matches a single node, captures as `$NAME` |\n" +
"| `$$$ARGS` | Matches zero or more nodes (variadic capture) |\n" + "| `$$$ARGS` | Matches zero or more nodes (variadic capture) |\n" +
"| `$_` | Wildcard — matches any single node, no capture |\n\n" + "| `$_` | Wildcard — matches any single node, no capture |\n\n" +
"## Matching semantics (important)\n\n" +
"Matching is keyword- and kind-based, not full structural matching. The leading keyword " +
"(`func`/`function`/`class`/`struct`/`interface`) plus placeholders select the node KIND; " +
"argument shapes, return types, and the literal bound to `$NAME` are NOT enforced as filters. " +
"So `func $NAME($$$ARGS) error` matches every function/method, not only those returning `error`. " +
"Captured `$VAR` values appear in output but do not constrain which nodes match.\n\n" +
"For precise selection, combine the pattern with `name_exact`, `name_matches`, and/or `kind_in` — " +
"these ARE applied as filters.\n\n" +
"## Scope\n\n" +
"The walk skips hidden directories (dot-prefixed) and dependency directories " +
"(`node_modules`, `vendor`, `bower_components`) — these hold third-party code that would " +
"flood results. To query inside one, name it explicitly in `paths` (e.g. `\"paths\": [\"vendor/foo\"]`); " +
"the targeted root is never skipped. Note: unlike file_search, ast_query does not consult .gitignore.\n\n" +
"## Examples\n\n" + "## Examples\n\n" +
"```json\n" + "```json\n" +
"// All Go functions returning error\n" + "// All Go functions/methods (the 'error' return is NOT enforced — add name_/kind_in to narrow)\n" +
`{"pattern": "func $NAME($$$ARGS) error", "language": "go"}` + "\n\n" + `{"pattern": "func $NAME($$$ARGS) error", "language": "go"}` + "\n\n" +
"// Python classes\n" + "// Python classes\n" +
`{"pattern": "class $NAME: $$$BODY", "language": "python"}` + "\n\n" + `{"pattern": "class $NAME: $$$BODY", "language": "python"}` + "\n\n" +
@@ -114,10 +127,16 @@ const helpLSPQuery = "# lsp_query — flags and examples\n\n" +
"- `compact=true` — collapse to one line per file\n" + "- `compact=true` — collapse to one line per file\n" +
"- `verbose=true` — add `Found N reference(s):` header\n\n" + "- `verbose=true` — add `Found N reference(s):` header\n\n" +
"Note: `include_declaration` and `compact` are errors when used with actions other than `references`.\n\n" + "Note: `include_declaration` and `compact` are errors when used with actions other than `references`.\n\n" +
"## Position: line+column or symbol_name\n\n" +
"Give the position directly with `line`+`column`, or pass `symbol_name` (optionally `symbol_kind`) to " +
"resolve it from the AST — handy when you know the name but not the coordinates. Ambiguous or unknown " +
"names return an error (with candidates / a 'did you mean' hint).\n\n" +
"## Examples\n\n" + "## Examples\n\n" +
"```json\n" + "```json\n" +
"// Hover — type/doc at position\n" + "// Hover — type/doc at position\n" +
`{"action": "hover", "file": "server.go", "line": 45, "column": 6}` + "\n\n" + `{"action": "hover", "file": "server.go", "line": 45, "column": 6}` + "\n\n" +
"// Definition by symbol name — no line/column needed\n" +
`{"action": "definition", "file": "server.go", "symbol_name": "NewServer"}` + "\n\n" +
"// Definition — where is this symbol defined?\n" + "// Definition — where is this symbol defined?\n" +
`{"action": "definition", "file": "handler.go", "line": 23, "column": 10}` + "\n\n" + `{"action": "definition", "file": "handler.go", "line": 23, "column": 10}` + "\n\n" +
"// References — all usages\n" + "// References — all usages\n" +
@@ -137,6 +156,8 @@ const helpEditApply = "# edit_apply — flags and examples\n\n" +
"| `none` | Empty response (silent success) |\n\n" + "| `none` | Empty response (silent success) |\n\n" +
"`compact_response=true` is a deprecated alias for `response=\"count\"` kept for pre-v2 compatibility.\n\n" + "`compact_response=true` is a deprecated alias for `response=\"count\"` kept for pre-v2 compatibility.\n\n" +
"For code files (Go, TypeScript, JavaScript, Python, C, C++, Rust) syntax is validated before writing — the edit is rejected if it would produce invalid syntax.\n\n" + "For code files (Go, TypeScript, JavaScript, Python, C, C++, Rust) syntax is validated before writing — the edit is rejected if it would produce invalid syntax.\n\n" +
"## new_content handling\n\n" +
"`new_content` is inserted **verbatim**. Send it as normal JSON — real newlines (or JSON `\\n`), with quotes JSON-escaped as usual. The server does not re-interpret escape sequences, so literal backslash sequences in source (`\\n`, `\\t`, `\\\\`, regexes, Windows paths) are written exactly as given. Indentation is not auto-applied — include the leading whitespace you want. Line endings are normalized to the file's existing convention (LF or CRLF).\n\n" +
"## Selector types\n\n" + "## Selector types\n\n" +
"### AST-mode selectors (code files)\n" + "### AST-mode selectors (code files)\n" +
"- `selector_kind` — AST node type (e.g. `function_declaration`, `class_declaration`)\n" + "- `selector_kind` — AST node type (e.g. `function_declaration`, `class_declaration`)\n" +
+17 -7
View File
@@ -129,6 +129,9 @@ func (s *Server) registerTools() {
mcp.WithBoolean("ignore_case", mcp.WithBoolean("ignore_case",
mcp.Description("Case insensitive search"), mcp.Description("Case insensitive search"),
), ),
mcp.WithBoolean("include_hidden",
mcp.Description("Search hidden files and directories (dotfiles like .github/). Default: false."),
),
mcp.WithBoolean("regex", mcp.WithBoolean("regex",
mcp.Description("Treat pattern as regex (default: true)"), mcp.Description("Treat pattern as regex (default: true)"),
), ),
@@ -222,6 +225,8 @@ func (s *Server) registerTools() {
s.mcp.AddTool( s.mcp.AddTool(
mcp.NewTool("ast_query", mcp.NewTool("ast_query",
mcp.WithDescription("Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types. "+ mcp.WithDescription("Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types. "+
"Matching is keyword/kind-based, not fully structural: argument and return-type constraints in the pattern are not enforced — use name_exact/name_matches/kind_in to filter precisely. "+
"Hidden dirs and dependency dirs (node_modules, vendor, bower_components) are skipped unless targeted explicitly via paths. "+
"See resource filepuff://help/ast_query for flags and examples."), "See resource filepuff://help/ast_query for flags and examples."),
mcp.WithReadOnlyHintAnnotation(true), mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("pattern", mcp.WithString("pattern",
@@ -250,7 +255,7 @@ func (s *Server) registerTools() {
mcp.Description("Maximum number of results to return (default: 100, page size for pagination)"), mcp.Description("Maximum number of results to return (default: 100, page size for pagination)"),
), ),
mcp.WithString("format", mcp.WithString("format",
mcp.Description("Output format: \"verbose\" (default, full code+captures), \"compact\" (one line per match), \"location\" (file:line only)"), mcp.Description("Output format: \"compact\" (default, one line per match), \"verbose\" (full code+captures), \"location\" (file:line only)"),
), ),
mcp.WithString("cursor", mcp.WithString("cursor",
mcp.Description("Pagination cursor from a previous truncated response. Pass back to fetch the next page."), mcp.Description("Pagination cursor from a previous truncated response. Pass back to fetch the next page."),
@@ -266,7 +271,8 @@ func (s *Server) registerTools() {
if s.lspManager != nil { if s.lspManager != nil {
s.mcp.AddTool( s.mcp.AddTool(
mcp.NewTool("lsp_query", mcp.NewTool("lsp_query",
mcp.WithDescription("Query LSP for symbol info, definition, or references at a specific file position. "+ mcp.WithDescription("Query LSP for symbol info, definition, or references at a file position. "+
"Give the position as line+column, or pass symbol_name to resolve it via AST (no line/column needed). "+
"See resource filepuff://help/lsp_query for flags and examples."), "See resource filepuff://help/lsp_query for flags and examples."),
mcp.WithReadOnlyHintAnnotation(true), mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("action", mcp.WithString("action",
@@ -277,13 +283,17 @@ func (s *Server) registerTools() {
mcp.Required(), mcp.Required(),
mcp.Description("Path to the file"), mcp.Description("Path to the file"),
), ),
mcp.WithString("symbol_name",
mcp.Description("Resolve the position from a named symbol (function, struct, class, etc.) instead of line+column. Errors if the name is ambiguous or not found."),
),
mcp.WithString("symbol_kind",
mcp.Description("Disambiguate symbol_name by kind when multiple symbols share the name. Accepted: function, method, struct, class, interface, type, enum, trait, constant, module."),
),
mcp.WithNumber("line", mcp.WithNumber("line",
mcp.Required(), mcp.Description("Line number (1-indexed). Required unless symbol_name is given."),
mcp.Description("Line number (1-indexed)"),
), ),
mcp.WithNumber("column", mcp.WithNumber("column",
mcp.Required(), mcp.Description("Column number (1-indexed). Required unless symbol_name is given."),
mcp.Description("Column number (1-indexed)"),
), ),
mcp.WithBoolean("include_declaration", mcp.WithBoolean("include_declaration",
mcp.Description("Include the declaration in results. Only valid for action=references (default: true)."), mcp.Description("Include the declaration in results. Only valid for action=references (default: true)."),
@@ -313,7 +323,7 @@ func (s *Server) registerTools() {
mcp.Description("Edit operation: replace, insert_before, insert_after, delete"), mcp.Description("Edit operation: replace, insert_before, insert_after, delete"),
), ),
mcp.WithString("new_content", mcp.WithString("new_content",
mcp.Description("New content (required for replace/insert operations)"), mcp.Description("New content (required for replace/insert operations). Inserted verbatim: send it as normal JSON (real newlines, quotes JSON-escaped as usual). The server does NOT re-interpret escape sequences, so backslash sequences in source code (e.g. \\n, \\t, \\\\ inside string literals, regexes, or Windows paths) are preserved exactly. Indentation is not auto-applied — include the leading whitespace you want. Line endings are normalized to the file's existing convention (LF or CRLF)."),
), ),
// AST-mode selectors (for code files) // AST-mode selectors (for code files)
mcp.WithString("selector_kind", mcp.WithString("selector_kind",
+26
View File
@@ -415,6 +415,32 @@ func TestSplitLinesLargeFileNoTrailingNewline(t *testing.T) {
} }
} }
// TestCountLines verifies countLines reports source line counts without the
// phantom trailing line that len(splitLines(...)) produces for newline-terminated
// files.
func TestCountLines(t *testing.T) {
tests := []struct {
name string
content string
want int
}{
{name: "empty", content: "", want: 0},
{name: "single line no newline", content: "a", want: 1},
{name: "single line trailing newline", content: "a\n", want: 1},
{name: "two lines trailing newline", content: "a\nb\n", want: 2},
{name: "two lines no trailing newline", content: "a\nb", want: 2},
{name: "blank lines", content: "\n\n\n", want: 3},
{name: "crlf trailing", content: "a\r\nb\r\n", want: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := countLines([]byte(tt.content)); got != tt.want {
t.Errorf("countLines(%q) = %d, want %d", tt.content, got, tt.want)
}
})
}
}
// TestSplitLinesLongLine verifies the scanner gracefully handles very long lines // TestSplitLinesLongLine verifies the scanner gracefully handles very long lines
// (up to the 1MB buffer limit set in C-05 fix). // (up to the 1MB buffer limit set in C-05 fix).
func TestSplitLinesLongLine(t *testing.T) { func TestSplitLinesLongLine(t *testing.T) {
+2 -2
View File
@@ -199,7 +199,7 @@ func TestSessionPrefsASTQueryMaxResults(t *testing.T) {
var sb strings.Builder var sb strings.Builder
sb.WriteString("package main\n\n") sb.WriteString("package main\n\n")
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
sb.WriteString(fmt.Sprintf("func Fn%c() {}\n\n", rune('A'+i))) fmt.Fprintf(&sb, "func Fn%c() {}\n\n", rune('A'+i))
} }
testFile := filepath.Join(tmpDir, "many.go") testFile := filepath.Join(tmpDir, "many.go")
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil { if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
@@ -281,7 +281,7 @@ func TestSessionPrefsMaxResultsExplicitOverride(t *testing.T) {
var sb strings.Builder var sb strings.Builder
sb.WriteString("package main\n\n") sb.WriteString("package main\n\n")
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
sb.WriteString(fmt.Sprintf("func Fn%c() {}\n\n", rune('A'+i))) fmt.Fprintf(&sb, "func Fn%c() {}\n\n", rune('A'+i))
} }
testFile := filepath.Join(tmpDir, "many.go") testFile := filepath.Join(tmpDir, "many.go")
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil { if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
+20
View File
@@ -0,0 +1,20 @@
package util
import "unicode/utf8"
// RuneBoundary returns the largest index j <= i such that s[:j] is valid UTF-8
// (j falls on a rune start, never inside a multibyte sequence). i is clamped to
// [0, len(s)]. Callers slicing s[:i] for truncation should slice s[:RuneBoundary(s, i)]
// instead so a multibyte character is never split into an invalid encoding.
func RuneBoundary(s string, i int) int {
if i <= 0 {
return 0
}
if i >= len(s) {
return len(s)
}
for i > 0 && !utf8.RuneStart(s[i]) {
i--
}
return i
}
+37
View File
@@ -0,0 +1,37 @@
package util
import (
"testing"
"unicode/utf8"
)
func TestRuneBoundary(t *testing.T) {
// "café" — 'é' is 2 bytes (0xC3 0xA9), occupying indices 3,4. len == 5.
const cafe = "café"
cases := []struct {
name string
s string
i int
want int
}{
{"negative clamps to zero", "abc", -1, 0},
{"zero", "abc", 0, 0},
{"beyond length clamps to len", "abc", 99, 3},
{"ascii exact boundary", "abcdef", 4, 4},
{"inside multibyte backs off", cafe, 4, 3}, // index 4 is the 2nd byte of 'é'
{"on multibyte start stays", cafe, 3, 3}, // index 3 is the 1st byte of 'é'
{"end of multibyte string", cafe, 5, 5}, // len == 5, clamped path
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := RuneBoundary(c.s, c.i)
if got != c.want {
t.Fatalf("RuneBoundary(%q, %d) = %d, want %d", c.s, c.i, got, c.want)
}
// Invariant: the prefix is always valid UTF-8.
if !utf8.ValidString(c.s[:got]) {
t.Fatalf("RuneBoundary(%q, %d) sliced invalid UTF-8: %q", c.s, c.i, c.s[:got])
}
})
}
}
+5 -5
View File
@@ -77,24 +77,24 @@ func (e *StructuredError) Error() string {
var sb strings.Builder var sb strings.Builder
// Error code and message // Error code and message
sb.WriteString(fmt.Sprintf("[%d] %s", e.Code, e.Message)) fmt.Fprintf(&sb, "[%d] %s", e.Code, e.Message)
// Context if available // Context if available
if len(e.Context) > 0 { if len(e.Context) > 0 {
sb.WriteString("\nContext:") sb.WriteString("\nContext:")
for k, v := range e.Context { for k, v := range e.Context {
sb.WriteString(fmt.Sprintf("\n %s: %v", k, v)) fmt.Fprintf(&sb, "\n %s: %v", k, v)
} }
} }
// Remediation if available // Remediation if available
if e.Remediation != "" { if e.Remediation != "" {
sb.WriteString(fmt.Sprintf("\nHow to fix: %s", e.Remediation)) fmt.Fprintf(&sb, "\nHow to fix: %s", e.Remediation)
} }
// Underlying cause if available // Underlying cause if available
if e.Cause != nil { if e.Cause != nil {
sb.WriteString(fmt.Sprintf("\nCaused by: %v", e.Cause)) fmt.Fprintf(&sb, "\nCaused by: %v", e.Cause)
} }
return sb.String() return sb.String()
@@ -215,7 +215,7 @@ func captureStack(skip int) string {
for { for {
frame, more := frames.Next() frame, more := frames.Next()
if !strings.Contains(frame.File, "runtime/") { if !strings.Contains(frame.File, "runtime/") {
sb.WriteString(fmt.Sprintf("\n %s:%d %s", frame.File, frame.Line, frame.Function)) fmt.Fprintf(&sb, "\n %s:%d %s", frame.File, frame.Line, frame.Function)
} }
if !more { if !more {
break break
+26 -23
View File
@@ -59,12 +59,10 @@ detect_platform() {
;; ;;
esac esac
# Intel Macs are no longer receiving macOS updates; we only ship Apple # Warn about unsupported combinations
# Silicon builds. Users on older hardware can build from source. if [ "$os" = "linux" ] && [ "$arch" = "arm64" ]; then
if [ "$os" = "darwin" ] && [ "$arch" = "amd64" ]; then print_error "Linux ARM64 builds are not currently available due to CGO cross-compilation complexity"
print_error "macOS Intel (x86_64) is no longer supported." print_error "Please build from source: https://github.com/lukaszraczylo/filepuff-mcp#build-from-source"
print_error "Apple Silicon binaries are available; for Intel, build from source:"
print_error " https://github.com/lukaszraczylo/filepuff-mcp#build-from-source"
exit 1 exit 1
fi fi
@@ -75,7 +73,7 @@ check_dependencies() {
local missing_deps=() local missing_deps=()
# Check for required commands # Check for required commands
for cmd in curl; do for cmd in curl tar; do
if ! command -v "$cmd" &> /dev/null; then if ! command -v "$cmd" &> /dev/null; then
missing_deps+=("$cmd") missing_deps+=("$cmd")
fi fi
@@ -212,15 +210,14 @@ download_and_install() {
print_info "Downloading mcp-filepuff ${version} for ${platform}..." print_info "Downloading mcp-filepuff ${version} for ${platform}..."
# Releases ship raw binaries named mcp-filepuff_<version>_<os>_<arch> # Construct download URLs
# (with .exe on Windows — not handled here). local archive_name="mcp-filepuff_${version#v}_${platform}.tar.gz"
local asset_name="mcp-filepuff_${version#v}_${platform}" local archive_url="https://github.com/${REPO}/releases/download/${version}/${archive_name}"
local asset_url="https://github.com/${REPO}/releases/download/${version}/${asset_name}"
local checksum_url="https://github.com/${REPO}/releases/download/${version}/checksums.txt" local checksum_url="https://github.com/${REPO}/releases/download/${version}/checksums.txt"
# Download binary # Download archive
if ! curl -sSfL "$asset_url" -o "$tmpdir/$asset_name"; then if ! curl -sSfL "$archive_url" -o "$tmpdir/$archive_name"; then
print_error "Failed to download $asset_url" print_error "Failed to download $archive_url"
exit 1 exit 1
fi fi
@@ -230,24 +227,26 @@ download_and_install() {
else else
print_info "Verifying checksum..." print_info "Verifying checksum..."
cd "$tmpdir" cd "$tmpdir"
# Extract expected checksum for our archive
local expected_checksum local expected_checksum
expected_checksum=$(grep "$asset_name" checksums.txt | awk '{print $1}') expected_checksum=$(grep "$archive_name" checksums.txt | awk '{print $1}')
if [ -z "$expected_checksum" ]; then if [ -z "$expected_checksum" ]; then
print_warn "Checksum not found for $asset_name, skipping verification" print_warn "Checksum not found for $archive_name, skipping verification"
else else
# Calculate actual checksum (use shasum on macOS, sha256sum on Linux)
local actual_checksum local actual_checksum
if command -v sha256sum &> /dev/null; then if command -v sha256sum &> /dev/null; then
actual_checksum=$(sha256sum "$asset_name" | awk '{print $1}') actual_checksum=$(sha256sum "$archive_name" | awk '{print $1}')
elif command -v shasum &> /dev/null; then elif command -v shasum &> /dev/null; then
actual_checksum=$(shasum -a 256 "$asset_name" | awk '{print $1}') actual_checksum=$(shasum -a 256 "$archive_name" | awk '{print $1}')
else else
print_warn "No checksum utility found, skipping verification" print_warn "No checksum utility found, skipping verification"
cd - > /dev/null cd - > /dev/null
return return
fi fi
if [ "$expected_checksum" = "$actual_checksum" ]; then if [ "$expected_checksum" = "$actual_checksum" ]; then
print_info "Checksum verification passed" print_info "Checksum verification passed"
else else
@@ -260,6 +259,10 @@ download_and_install() {
cd - > /dev/null cd - > /dev/null
fi fi
# Extract archive
print_info "Extracting archive..."
tar -xzf "$tmpdir/$archive_name" -C "$tmpdir"
# Ensure install directory exists # Ensure install directory exists
if [ ! -d "$INSTALL_DIR" ]; then if [ ! -d "$INSTALL_DIR" ]; then
print_info "Creating installation directory: $INSTALL_DIR" print_info "Creating installation directory: $INSTALL_DIR"
@@ -269,11 +272,11 @@ download_and_install() {
# Install binary # Install binary
print_info "Installing to $INSTALL_DIR/$BINARY_NAME..." print_info "Installing to $INSTALL_DIR/$BINARY_NAME..."
if [ -w "$INSTALL_DIR" ]; then if [ -w "$INSTALL_DIR" ]; then
cp "$tmpdir/$asset_name" "$INSTALL_DIR/$BINARY_NAME" cp "$tmpdir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
chmod +x "$INSTALL_DIR/$BINARY_NAME" chmod +x "$INSTALL_DIR/$BINARY_NAME"
else else
print_warn "No write permission to $INSTALL_DIR, trying with sudo..." print_warn "No write permission to $INSTALL_DIR, trying with sudo..."
sudo cp "$tmpdir/$asset_name" "$INSTALL_DIR/$BINARY_NAME" sudo cp "$tmpdir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
sudo chmod +x "$INSTALL_DIR/$BINARY_NAME" sudo chmod +x "$INSTALL_DIR/$BINARY_NAME"
fi fi
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Invoked by lukaszraczylo/shared-actions go-release-cgo.yaml before GoReleaser,
# with TARGET_GOOS/TARGET_GOARCH set from the build matrix entry.
#
# GoReleaser Pro `release --split` partitions builds across matrix runners.
# Its default partition is by GOOS only, so every linux runner would build
# BOTH linux/amd64 and linux/arm64. With CGO_ENABLED=1 (go-tree-sitter needs
# CGO) the native gcc on one runner cannot assemble the other arch's CGO
# runtime (`gcc_arm64.S: no such instruction`), failing the release.
#
# Exporting GGOOS/GGOARCH restricts each runner to its own target. These are
# filter-only vars (unlike GOOS/GOARCH, which bleed into before-hooks such as
# `go run`/`go generate`) and require `partial.by: target` in .goreleaser.yaml.
set -euo pipefail
if [ -n "${GITHUB_ENV:-}" ] && [ -n "${TARGET_GOOS:-}" ] && [ -n "${TARGET_GOARCH:-}" ]; then
echo "GGOOS=${TARGET_GOOS}" >>"${GITHUB_ENV}"
echo "GGOARCH=${TARGET_GOARCH}" >>"${GITHUB_ENV}"
echo "Pinned GoReleaser split target: GGOOS=${TARGET_GOOS} GGOARCH=${TARGET_GOARCH}"
fi