Compare commits

..
85 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub 700c6e818c Update go.mod and go.sum (#38) 2026-09-03 05:25:49 +01:00
lukaszraczyloandGitHub 25ee197acd Update go.mod and go.sum (#37) 2026-08-12 04:44:27 +01:00
lukaszraczyloandGitHub a7dcab5146 Update go.mod and go.sum (#36) 2026-08-07 04:58:19 +01:00
lukaszraczyloandGitHub d9d8332f77 Update go.mod and go.sum (#35) 2026-07-24 06:46:58 +01:00
lukaszraczyloandGitHub a4bfdefdab Update go.mod and go.sum (#34) 2026-07-10 05:23:17 +01:00
lukaszraczyloandGitHub 32b0a7f69a Update go.mod and go.sum (#33) 2026-07-09 05:20:53 +01:00
lukaszraczyloandGitHub 76316de768 Update go.mod and go.sum (#32) 2026-07-07 05:22:27 +01:00
lukaszraczyloandGitHub 9abab7a550 Update go.mod and go.sum (#31) 2026-06-26 05:30:00 +01:00
lukaszraczylo 658c895318 fix(lsp): detect, evict, and respawn crashed language servers
A crashed language server stayed cached with ready==true: readLoop exited and
drained in-flight calls but never cleared the running flag, and GetServer keyed
reuse on ready alone. Every subsequent hover/definition/references then passed
the running guard, wrote to a dead stdin, and blocked the full request timeout —
permanently, since active use kept the server off the idle reaper. A single
crash wedged that language at ~10s/call with no self-heal.

- readLoop now flips running=false on exit, so Call fails fast instead of hanging.
- GetServer treats ready && client.IsRunning() as the liveness condition; a stale
  entry is evicted (closed async — its process has already exited) and a fresh
  server is spawned in its place.
- drainPending's send is now non-blocking, matching the dispatch path, so a
  duplicate-id race can't deadlock it under c.mu.

Test (proven to fail without the fix): a client whose process exits is marked
not-running and its next Call fails fast rather than blocking.
2026-06-21 02:25:23 +01:00
lukaszraczylo d24c8218fd refactor: remove dead scaffolding from parser and edit
edit.ValidateLanguage had zero callers (only its own godoc referenced it) —
removed.

yaml_json.go: contrary to the audit's claim, the file is NOT entirely dead —
ParseYAML/ParseJSON are live via the Parse() dispatch (parser.go:198-200) and
are kept with their tests. Removed only the genuinely unreachable scaffolding
that nothing constructs or calls: the YAMLNode/JSONNode types, WalkYAML,
GetYAMLNodeText, GetYAMLNodeLocation, QueryYAML, QueryJSON, ValidateYAML,
ValidateJSON, and ToSitterTree, plus their now-orphaned tests. YAML/JSON parsing
behavior is unchanged (TestParseYAML/TestParseJSON/TestRegistryParse_YAML_JSON
still pass).
2026-06-21 02:21:37 +01:00
lukaszraczylo f4c34c41e4 test(edit): cover selector_index bounds, overlap, and CRLF splice
Add behavior tests for the highest-risk under-tested mutation paths: findExactText
(overlapping-match index resolution + out-of-range/empty/not-found errors),
findRegexPattern (non-overlapping index resolution + out-of-range/invalid-regex/
not-found errors), and spliceContent's CRLF pullback (an edit ending between \r
and \n must not orphan a bare LF). These resolve the byte range that gets mutated,
so wrong handling means wrong-location or line-ending-corrupting writes.
2026-06-21 02:18:15 +01:00
lukaszraczylo 749076518d perf(lsp): default find_references to compact output
References defaulted to one line per reference; compact (one line per file,
columns collapsed) existed only as opt-in. Compact preserves every line:col pair
with zero information loss while cutting roughly one line per reference for the
LLM consumer. Default it to true; compact=false and the session compact_refs
pref still select the verbose per-reference layout (explicit override wins).
2026-06-21 02:16:40 +01:00
lukaszraczylo 664b7dac45 perf(file_read): compact line-number prefix by default
The default line prefix was the padded '  42| ' form; the compact '42|' form
existed only as opt-in (compact_line_numbers). The padding and trailing space
carry no information an LLM needs, costing ~3 chars/line — ~300 tokens on a
400-line read, the highest-volume path in the server. Flip the default to
compact; compact_line_numbers=false, mode/line_numbers=full, and the session
'full' pref all still restore the padded alignment (explicit override wins).

Updates the two tests that pinned the old default and refreshes stale comments.
2026-06-21 02:15:49 +01:00
lukaszraczylo d0c07f0157 fix(edit): re-validate path immediately before write (TOCTOU hardening)
edit_apply validated the target with IsPathAllowed in the handler, then later
wrote it via os.WriteFile on the raw path after reading+parsing+diffing. A
symlink swapped in during that window could redirect the write outside the
workspace root. Inject the admission check into the edit Engine
(NewEngineWithValidator) and re-run it inside a new writeValidatedFile helper
immediately before the write — IsPathAllowed resolves symlinks at write time, so
a swapped-in link now resolving outside the root is rejected. This narrows the
TOCTOU window to near-zero while still permitting legitimate in-workspace symlink
edits (unlike a blanket O_NOFOLLOW). writeValidatedFile also dedups the
perm-preserving write shared by AST and text modes.

Tests: rejected-path edit leaves the file untouched; file mode is preserved.
2026-06-21 02:12:57 +01:00
lukaszraczylo a841210092 perf(lsp): emit workspace-relative paths in definition/references
lsp_query printed absolute paths from URIToFile (e.g. the full
/Users/.../internal/... prefix repeated per result) while file_search and the
AST summary already relativize. On a find_references across many files that
~60-char root prefix repeats per file group — wasted tokens for the LLM
consumer. Extract a Server.relPath helper (dedups the AST summary's inline
block) and apply it to definition and references display paths; the absolute
path is kept where the file is actually re-read for previews.

Adds a relPath unit test (in-workspace relativized, out-of-workspace unchanged).
2026-06-21 02:03:43 +01:00
lukaszraczylo 98a499ab20 fix(parser): guard FindNodeAtPosition against position underflow
line/col are 1-indexed and converted via uint32(line-1); a caller passing 0
wrapped to 4294967295, feeding a garbage Point into tree-sitter. Reachable from
the LSP-down AST fallback with caller-supplied coordinates. Reject non-positive
positions up front. Adds tests (the function was previously 0% covered).
2026-06-21 02:01:50 +01:00
lukaszraczylo 8d940022b7 fix(search): drop matches outside the workspace root (symlink escape)
file_search runs ripgrep with --follow on by default, so a symlink inside the
workspace pointing outside it (e.g. link -> /etc) caused rg to traverse out and
return matches from external files — leaking their content to the caller despite
the workspace-scoping contract. parseOutput emitted match paths verbatim with no
re-validation.

Filter every match through IsPathAllowed (resolving rg's workspace-relative path
against the root first); IsPathAllowed's EvalSymlinks rejects the escape while
still permitting legitimate worktree siblings. Defense in depth that also covers
--no-ignore and any other traversal that reaches outside the root.

Adds a security regression test (proven to fail without the filter).
2026-06-21 01:59:53 +01:00
lukaszraczylo 99de8a93b9 test(search): cover cross-file context attribution in parseOutput
parseOutput's pendingFile guard attaches before-context only when it belongs to the same file as the following match. Add tests for both the cross-file leak guard (fileA context must not attach to a fileB match) and the same-file positive case. Test-only; no behavior change.
2026-06-21 00:07:08 +01:00
lukaszraczylo a11623cb0d perf(edit_apply): drop redundant Diff: label; docs(file_read): note strip renumbering
edit_apply response=diff prefixed the fenced ```diff block with a 'Diff:'
label that the fence and the diff's own ---/+++ headers already convey — pure
token cost per diff response. Extract fencedDiff() (also dedups the diff and
default branches) and drop the label. Adds a test guard.

Also document that strip renumbers lines from 1 over the stripped content, so
the model doesn't cite original-file line numbers after a stripped read.
2026-06-20 02:12:46 +01:00
lukaszraczylo 5f0bb90e28 fix(ast_query): reliable first-page pagination cursor
The page-1 walk was capped at max_results via SkipAll, but buildASTCursorFooter
derives the remaining count from the total number of matches seen. Capping
truncated that total, so on page 1 the cursor fired only when the last parsed
file happened to overshoot the cap — nondeterministic — and reported a wrong
remaining count when it did. Same failure class as the file_search first-page
cursor bug.

Always walk the full tree (pages 2+ already did). The common case where matches
fit in one page already visited everything (SkipAll never triggered), and parse
results are LRU-cached, so the extra cost falls only on multi-page queries and is
mostly file I/O.

Adds a strict regression test asserting page 1 emits a cursor with an accurate
remaining count; the existing pagination test silently skipped when no cursor.
2026-06-20 01:44:47 +01:00
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
lukaszraczyloandGitHub f1643e7b81 Fix/release drop broken static (#13)
* fix(release): drop broken CGO_ENABLED=0 static build target

smacker/go-tree-sitter requires CGO: the Node type is defined only in
bindings.go behind //go:build cgo, while iter.go references it without
a build tag. With CGO_ENABLED=0 every iter.go:N: undefined: Node fails
the build.

The mcp-filepuff-static target was added in 9205b2b after the last
successful release (v0.2.3) and has never produced an artifact — every
release since has been red. Dropping it restores the working release
shape: darwin_arm64 + linux_amd64 + windows_amd64 CGO binaries, same
as v0.2.3 shipped.

* feat(release): ship darwin_amd64 and linux_arm64 binaries

Shared go-release-cgo.yaml runs each matrix entry natively (no cross-
compile), so adding platforms only needs two edits: a platforms input
in release.yaml and removal of ignore rules in .goreleaser.yaml.

New coverage:
- darwin_amd64 on macos-13 runner (Intel Mac)
- linux_arm64 on ubuntu-24.04-arm runner (ARM servers, Raspberry Pi,
  Apple Silicon via Docker/QEMU)

Kept: darwin_arm64, linux_amd64, windows_amd64.
Still skipped: windows_arm64 (uncommon, ARM Windows runner flakier).
2026-04-19 20:22:55 +01:00
lukaszraczyloandGitHub 1fe8db9012 ci: bump golangci-lint-action to v8 with pinned v2.9.0 (#12)
.golangci.yml declares version: "2" (new schema) but CI was using
golangci-lint-action@v6, which ships golangci-lint v1.64.8. That
binary is built with go1.24 and cannot load a config targeting
go1.25, so every lint run on main failed with:

  can't load config: the Go language version (go1.24) used to build
  golangci-lint is lower than the targeted Go version (1.25)

golangci-lint-action@v8 supports golangci-lint v2.x. Pin v2.9.0 to
match the locally-used version and keep the binary toolchain aligned
with the go1.25 project target.
2026-04-19 20:02:54 +01:00
lukaszraczyloandGitHub 5ad975ee7a V2/token optimization (#11)
* v2.0: token-optimization overhaul

Additive (backward-compatible flags):
- file_read: skeleton mode, strip (imports/license/block_comments),
  compact_line_numbers, 8-char etag with prefix-match compat
- ast_query: format=verbose|compact|location, pagination cursor
- file_search: cluster mode, pagination cursor
- lsp_query (references): compact output

Breaking (v2):
- Preambles removed; opt-in verbose=true restores
- edit_apply: response=count|diff|none, default count
- ping tool removed
- symbol_at/find_definition/find_references merged into lsp_query
- Tool descriptions trimmed -83%, help moved to filepuff://help/<tool>
- Batch file_read dedups by etag

Protocol:
- ResourceLink returned for file_read >64 KiB (force_inline override)
- OnAfterInitialize hook reads capabilities.experimental.filepuff
  for session defaults (default_format, default_max_results,
  default_cluster, compact_refs, line_numbers,
  resource_link_threshold)

* fix: drop --max-total-count from ripgrep args

The flag does not exist in stable ripgrep (confirmed up to 15.1.0 --
"unrecognized flag --max-total-count, similar flags that are
available: --max-count"). Every file_search call failed on hosts with
stock rg. --max-count is per-file, not a drop-in replacement, so rely
on the in-process truncation in parseOutput that was already the
documented safety net.
2026-04-19 19:56:49 +01:00
lukaszraczyloandGitHub b131c1edd3 Update go.mod and go.sum (#10) 2026-04-15 05:02:41 +01:00
lukaszraczyloandGitHub 7829ae4891 Update go.mod and go.sum (#9) 2026-04-09 04:58:25 +01:00
lukaszraczyloandGitHub 0d91e94416 Update go.mod and go.sum (#8) 2026-04-05 05:01:32 +01:00
lukaszraczyloandGitHub 7ad20c33f9 Update go.mod and go.sum (#7) 2026-03-27 03:58:58 +00:00
lukaszraczyloandGitHub 90244fda67 Update go.mod and go.sum (#6) 2026-03-20 03:48:30 +00:00
lukaszraczyloandGitHub 9a9d81f2a4 Update go.mod and go.sum (#5) 2026-03-15 03:59:02 +00:00
lukaszraczyloandGitHub 78c5fc7236 Update go.mod and go.sum (#4) 2026-03-13 03:48:53 +00:00
lukaszraczylo ccfbdc513f fixup! fixup! fixup! fixup! Add Docker usage instructions to README 2026-03-12 19:21:13 +00:00
lukaszraczylo db803c6950 fixup! fixup! fixup! Add Docker usage instructions to README 2026-03-12 10:42:39 +00:00
lukaszraczylo 34d4401280 fixup! fixup! Add Docker usage instructions to README 2026-03-11 13:18:59 +00:00
lukaszraczylo 267378181c fixup! Add Docker usage instructions to README 2026-03-11 02:53:12 +00:00
lukaszraczylo 2b83fccb35 Add Docker usage instructions to README 2026-03-07 18:55:05 +00:00
lukaszraczylo 996be39314 Add Docker image support with distroless container
- Dockerfile: distroless base (glibc) for CGO binary
- GoReleaser: Docker build + push to ghcr.io/lukaszraczylo/filepuff-mcp
- Cosign signing for Docker manifests
2026-03-07 18:47:22 +00:00
lukaszraczyloandGitHub 50da24756f Update go.mod and go.sum (#3) 2026-03-07 03:42:02 +00:00
lukaszraczyloandGitHub a778e0b28b Update go.mod and go.sum (#2) 2026-02-28 03:37:42 +00:00
lukaszraczylo e2dc4a1f40 multi-fixes 2026-02-22 15:24:48 +00:00
lukaszraczylo 982c2c8b44 fixup! Update, bugfixes on diff and edit handling 2026-02-22 14:03:54 +00:00
lukaszraczylo 6980d3b294 Update, bugfixes on diff and edit handling 2026-02-18 21:49:55 +00:00
lukaszraczylo 9205b2bc26 feat(docs, ci, config): add comprehensive documentation and tooling
- [x] Add API reference documentation with tool descriptions and examples
- [x] Add ERROR_CODES reference with error descriptions and remediation steps
- [x] Add PERFORMANCE tuning guide with caching and optimization details
- [x] Add GitHub Actions workflows for linting and security scanning
- [x] Add golangci-lint configuration with comprehensive linter settings
- [x] Add pre-commit hooks configuration for local development
- [x] Add API documentation generator tool (cmd/docgen)
- [x] Update Go version from 1.24 to 1.25 across workflows
- [x] Add static build configuration to goreleaser
- [x] Add metrics package with Prometheus-style metric types
- [x] Add parser benchmarks for performance testing
- [x] Add LSP manager integration tests
- [x] Add server integration tests with MCP protocol flow testing
- [x] Extract regex cache to shared utility package
- [x] Add context cancellation handling in AST queries
- [x] Add graceful shutdown with timeout to server
- [x] Add configurable max parse size (MaxParseSize)
- [x] Add Config.Validate() method with comprehensive checks
- [x] Add parser cache statistics tracking
- [x] Add file permission preservation in edit operations
- [x] Improve line splitting for large files with bufio.Scanner
- [x] Add comprehensive config tests for edge cases
- [x] Update Makefile with new targets and documentation
2026-02-18 21:49:54 +00:00
lukaszraczyloandGitHub 143a166249 Update go.mod and go.sum (#1) 2026-02-16 03:53:38 +00:00
lukaszraczylo 9d84c1253b test(edit): add comprehensive selector specificity tests
- [x] Add sortBySpecificity and shouldPrefer helper functions for node preference logic
- [x] Implement isDeclarationLike pattern matching for declaration/statement node types
- [x] Add AtLine selector specificity logic to prefer smallest meaningful nodes
- [x] Add TestResolveSelectorAtLineSpecificity to verify correct node selection
- [x] Add TestRegressionInsertAfterAtLine to prevent file corruption on insertions
- [x] Add TestRegressionInsertBeforeAtLine to verify insert-before ordering
- [x] Add TestRegressionNestedStructures to ensure nested nodes select correctly
- [x] Add TestRegressionPreservesFileIntegrity to verify unrelated content preservation
- [x] Add TestRegressionMultipleConstBlocks for multi-block const handling
- [x] Add TestSortBySpecificity unit test for sorting logic
2026-01-23 20:56:26 +00:00
lukaszraczylo b8d868115c feat(parser): add Elixir language support
- [x] Add Elixir documentation extraction (@doc and @moduledoc attributes)
- [x] Add Elixir symbol extraction (modules, functions, macros, structs, protocols)
- [x] Add tree-sitter Elixir language parser integration
- [x] Add Elixir language detection for .ex and .exs file extensions
- [x] Add Elixir symbol extraction tests
- [x] Update language support table in README
- [x] Improve install script with package manager detection and LSP installation
- [x] Fix shell script portability (replace echo -e with printf)
- [x] Fix checksum verification in install script for macOS/Linux compatibility
2026-01-23 20:31:08 +00:00
83 changed files with 18151 additions and 3104 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Lint
on:
push:
branches:
- main
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.golangci.yml'
pull_request:
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.golangci.yml'
permissions:
contents: read
jobs:
golangci:
name: golangci-lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.9.0
args: --timeout=5m
+9 -1
View File
@@ -18,6 +18,14 @@ jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
with:
go-version: "1.24"
go-version: "1.25"
rolling-release-tag: "v1"
platforms: >-
[
{"os":"macos-15-intel","goos":"darwin","goarch":"amd64","platform":"darwin_amd64"},
{"os":"macos-latest","goos":"darwin","goarch":"arm64","platform":"darwin_arm64"},
{"os":"ubuntu-latest","goos":"linux","goarch":"amd64","platform":"linux_amd64"},
{"os":"ubuntu-24.04-arm","goos":"linux","goarch":"arm64","platform":"linux_arm64"},
{"os":"windows-latest","goos":"windows","goarch":"amd64","platform":"windows_amd64"}
]
secrets: inherit
+41
View File
@@ -0,0 +1,41 @@
name: Security Scan
on:
push:
branches:
- main
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
pull_request:
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
schedule:
# Run weekly on Monday at 9:00 UTC
- cron: '0 9 * * 1'
permissions:
contents: read
jobs:
govulncheck:
name: Run govulncheck
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
+1
View File
@@ -1,3 +1,4 @@
TODO.md
bin/mcp-filepuff
mcp-filepuff
dist/
+44
View File
@@ -0,0 +1,44 @@
version: "2"
run:
timeout: 5m
go: '1.25'
linters:
enable:
- errcheck # Check for unchecked errors
- govet # Vet examines Go source code
- ineffassign # Detect ineffectual assignments
- staticcheck # Advanced static analysis (includes gosimple)
- unused # Check for unused code
- gocyclo # Check cyclomatic complexity
- misspell # Check for misspelled words
formatters:
enable:
- gofmt # Check formatting
linters-settings:
gocyclo:
min-complexity: 15
errcheck:
check-blank: true
govet:
enable-all: true
staticcheck:
checks: ["all"]
issues:
exclude-rules:
# Exclude some linters from running on tests
- path: _test\.go
linters:
- gocyclo
- errcheck
- gosec
max-issues-per-linter: 0
max-same-issues: 0
output:
sort-results: true
+32 -5
View File
@@ -5,13 +5,22 @@ version: 2
project_name: mcp-filepuff
# Split CGO builds by full target (GOOS+GOARCH), not just GOOS.
# Each release matrix runner builds natively for exactly one target via the
# 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:
by: target
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- id: mcp-filepuff
- id: mcp-filepuff-cgo
main: ./cmd/mcp-filepuff
binary: mcp-filepuff
env:
@@ -31,10 +40,6 @@ builds:
- amd64
- arm64
ignore:
- goos: darwin
goarch: amd64
- goos: linux
goarch: arm64
- goos: windows
goarch: arm64
@@ -90,6 +95,19 @@ release:
```
dockers:
- image_templates:
- "ghcr.io/lukaszraczylo/filepuff-mcp:{{ .Tag }}"
- "ghcr.io/lukaszraczylo/filepuff-mcp:latest"
ids:
- mcp-filepuff-cgo
use: buildx
build_flag_templates:
- "--platform=linux/amd64"
goarch: amd64
goos: linux
dockerfile: Dockerfile
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
@@ -101,4 +119,13 @@ signs:
artifacts: checksum
output: true
docker_signs:
- cmd: cosign
artifacts: manifests
output: true
args:
- "sign"
- "--yes"
- "${artifact}"
+52
View File
@@ -0,0 +1,52 @@
# Pre-commit hooks configuration
# See https://pre-commit.com for more information
# Installation: pip install pre-commit && pre-commit install
#
# Note: These hooks are optional for contributors.
# Run manually with: pre-commit run --all-files
repos:
# Standard pre-commit hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-merge-conflict
- id: check-added-large-files
args: ['--maxkb=500']
- id: detect-private-key
# Go formatting
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-imports
- id: go-vet
- id: go-mod-tidy
# golangci-lint
- repo: https://github.com/golangci/golangci-lint
rev: v1.56.2
hooks:
- id: golangci-lint
args: [--config=.golangci.yml]
# Markdown linting (optional)
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.39.0
hooks:
- id: markdownlint
args: ['--fix', '--disable', 'MD013', 'MD033', 'MD041', '--']
# Configuration for specific hooks
default_language_version:
golang: "1.22"
# Don't run on CI by default (handled by GitHub Actions)
ci:
skip: [golangci-lint, go-vet]
+3
View File
@@ -0,0 +1,3 @@
FROM gcr.io/distroless/base-debian12:nonroot
COPY mcp-filepuff /usr/local/bin/mcp-filepuff
ENTRYPOINT ["mcp-filepuff"]
+28 -3
View File
@@ -1,5 +1,4 @@
.PHONY: build test lint clean install run deps
.PHONY: build test lint clean install run deps docs
# Binary name
BINARY_NAME=mcp-filepuff
# Build directory
@@ -42,12 +41,29 @@ build-all:
# Run tests
test:
$(GOTEST) -v -race -coverprofile=coverage.out ./...
$(GOTEST) -v -coverprofile=coverage.out ./...
# Run tests with race detector on critical packages
test-race:
@echo "Running race detector tests on critical packages..."
$(GOTEST) -v -race -timeout=5m ./internal/edit/...
$(GOTEST) -v -race -timeout=5m ./internal/lsp/...
$(GOTEST) -v -race -timeout=5m ./internal/parser/...
$(GOTEST) -v -race -timeout=5m ./internal/server/...
@echo "Race detector tests completed successfully"
# Run tests with short flag
test-short:
$(GOTEST) -v -short ./...
# Run all tests including race detector
test-all: test test-race
# Run linters
lint:
@which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
golangci-lint run ./...
# Clean build artifacts
clean:
rm -rf $(BUILD_DIR)
@@ -72,9 +88,18 @@ help:
@echo " build - Build the binary"
@echo " build-all - Build for all platforms"
@echo " test - Run tests with coverage"
@echo " test-race - Run tests with race detector on critical packages"
@echo " test-all - Run all tests including race detector"
@echo " test-short - Run short tests"
@echo " lint - Run linters"
@echo " clean - Clean build artifacts"
@echo " install - Install binary to GOPATH/bin"
@echo " run - Build and run the server"
@echo " run-workspace - Run with specific workspace (WORKSPACE=/path)"
# Generate API documentation
docs:
@echo "Generating API documentation..."
$(GOCMD) run ./cmd/docgen
@echo "Documentation generated in docs/API.md"
+279 -14
View File
@@ -8,7 +8,7 @@ A Go-based MCP (Model Context Protocol) server for Claude Code providing intelli
- **AST-Aware File Reading**: Read files with symbol extraction using Tree-sitter
- **Code Pattern Matching**: Query code using patterns with capture placeholders
- **LSP Integration**: Go-to-definition, find references, and symbol info via language servers
- **Safe Editing**: AST-aware file editing with syntax validation and preview
- **Safe Editing**: AST-aware file editing with syntax validation (edit_apply)
- **Multi-Language Support**: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, React
- **Token Efficient**: Optimized for minimal token usage with symbols-only mode and output limiting
@@ -29,6 +29,30 @@ This script will:
- Install to `~/.local/bin` (or `/usr/local/bin` if needed)
- Make the binary executable
### Docker
```bash
docker pull ghcr.io/lukaszraczylo/filepuff-mcp:latest
```
The MCP server communicates over stdio. Mount your workspace and run with `-i`:
```bash
docker run -i --rm -v /path/to/workspace:/workspace ghcr.io/lukaszraczylo/filepuff-mcp:latest -workspace /workspace
```
Claude Code configuration (`.claude/settings.json`):
```json
{
"mcpServers": {
"filepuff": {
"command": "docker",
"args": ["run", "-i", "--rm", "-v", ".:/workspace", "ghcr.io/lukaszraczylo/filepuff-mcp:latest", "-workspace", "/workspace"]
}
}
}
```
### Manual Installation
Download pre-built binaries from the [releases page](https://github.com/lukaszraczylo/filepuff-mcp/releases):
@@ -104,6 +128,39 @@ After downloading or building the binary, configure it in Claude Code:
See the [Claude Code MCP documentation](https://code.claude.com/docs/en/mcp) for more details.
### Recommended Claude Code Configuration
#### Selective Tool Deferral
For optimal performance, keep the most frequently used tools loaded at startup and defer the rest. This follows [Anthropic's recommended practice](https://www.anthropic.com/engineering/advanced-tool-use) of loading 3-5 high-use tools immediately:
```json
{
"mcpServers": {
"filepuff": {
"command": "mcp-filepuff",
"args": ["-workspace", "."],
"alwaysAllow": ["file_read", "file_search", "edit_apply"]
}
}
}
```
This keeps `file_read`, `file_search`, and `edit_apply` immediately available while deferring less frequently used tools (`ping`, `ast_query`, `symbol_at`, `find_definition`, `find_references`).
#### System Prompt Guidance
Add the following to your `CLAUDE.md` to help Claude understand the available tool categories:
```markdown
You have access to filepuff MCP tools providing:
- File reading with AST symbol summaries (file_read)
- Fast regex search powered by ripgrep (file_search)
- Structural code pattern matching across 9+ languages (ast_query)
- LSP-powered go-to-definition, find-references, and symbol info (find_definition, find_references, symbol_at)
- AST-aware file editing with syntax validation (edit_apply)
```
## Usage
### Running the Server (Standalone)
@@ -184,7 +241,7 @@ When performing file operations, prefer filepuff MCP tools over built-in equival
| Read files | `mcp__filepuff__file_read` | Read |
| Search content | `mcp__filepuff__file_search` | Grep |
| AST pattern search | `mcp__filepuff__ast_query` | Grep/Glob |
| Edit files | `mcp__filepuff__edit_preview` + `mcp__filepuff__edit_apply` | Edit |
| Edit files | `mcp__filepuff__edit_apply` | Edit |
| Find definitions | `mcp__filepuff__find_definition` | Grep |
| Find references | `mcp__filepuff__find_references` | Grep |
| Symbol info | `mcp__filepuff__symbol_at` | - |
@@ -342,8 +399,8 @@ Find all references to the symbol at a specific position.
---
### `edit_preview`
Preview an edit without applying it. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++), and text-based editing for other files (Markdown, JSON, YAML, config files, etc.).
### `edit_apply`
Apply an edit to a file. Uses AST-aware editing for code files with syntax validation, and text-based editing for other files.
**Parameters**:
- `file` (required): Path to the file to edit
@@ -363,13 +420,6 @@ Preview an edit without applying it. Uses AST-aware editing for code files (Go,
- `selector_text`: Exact text to match (must be unique or use selector_index)
- `selector_pattern`: Regex pattern to match
---
### `edit_apply`
Apply an edit to a file. Uses AST-aware editing for code files with syntax validation, and text-based editing for other files.
**Parameters**: Same as `edit_preview`
**Example (AST mode - Go file)**:
```json
{
@@ -425,6 +475,7 @@ Apply an edit to a file. Uses AST-aware editing for code files with syntax valid
| HTML | .html, .htm | Yes | Yes | - | Yes |
| Vue | .vue | Yes | Yes* | - | Yes |
| React | .jsx, .tsx | Yes | Yes | typescript-language-server | Yes |
| Elixir | .ex, .exs | Yes | Yes | elixir-ls | Yes |
\* Vue uses HTML parser for template sections
@@ -479,13 +530,14 @@ make clean
## Architecture
### High-Level Overview
```
┌─────────────────────────────────────────────────────────┐
│ MCP Server │
├─────────────────────────────────────────────────────────┤
│ Tools: file_search, file_read, ast_query, symbol_at, │
│ find_definition, find_references, │
│ edit_preview, edit_apply, ping │
│ find_definition, find_references, edit_apply, ping │
├─────────────────────────────────────────────────────────┤
│ Core Engines │
├───────────┬─────────────┬────────────┬─────────────────┤
@@ -494,6 +546,220 @@ make clean
└───────────┴─────────────┴────────────┴─────────────────┘
```
### Detailed Sequence Diagrams
#### LSP Integration Flow
The following diagram shows how LSP requests (hover, definition, references) flow through the system:
```mermaid
sequenceDiagram
participant Client as MCP Client
participant Server as MCP Server
participant LSPMgr as LSP Manager
participant LSPSrv as LSP Server (gopls/etc)
participant FS as File System
Client->>Server: symbol_at(file, line, col)
activate Server
Server->>LSPMgr: GetServer(language)
activate LSPMgr
alt Server Not Running
LSPMgr->>LSPSrv: Start Process
LSPMgr->>LSPSrv: initialize request
LSPSrv-->>LSPMgr: capabilities
LSPMgr->>LSPSrv: initialized notification
end
LSPMgr-->>Server: ManagedServer
deactivate LSPMgr
Server->>LSPMgr: ensureDocumentOpen(file)
activate LSPMgr
alt Document Not Open
LSPMgr->>FS: ReadFile(path)
FS-->>LSPMgr: content
LSPMgr->>LSPSrv: textDocument/didOpen
end
LSPMgr-->>Server: ready
deactivate LSPMgr
Server->>LSPSrv: textDocument/hover
activate LSPSrv
LSPSrv-->>Server: HoverResult
deactivate LSPSrv
Server-->>Client: Symbol information
deactivate Server
```
#### Edit Operation Flow
The edit engine uses atomic writes and validation to ensure safe file modifications:
```mermaid
sequenceDiagram
participant Client as MCP Client
participant Server as MCP Server
participant Edit as Edit Engine
participant Parser as Parser Registry
participant FS as File System
Client->>Server: edit_apply(file, operation, selector, content)
activate Server
Server->>Edit: Apply(ctx, edit)
activate Edit
Edit->>Edit: lockFile(path)
Note over Edit: Per-file mutex prevents<br/>concurrent edits
Edit->>FS: ReadFile(path)
FS-->>Edit: original content
alt AST-Aware Mode (code files)
Edit->>Parser: Parse(ctx, path, content)
activate Parser
Parser-->>Edit: ParseResult with AST
deactivate Parser
Edit->>Edit: resolveSelector(selector, tree)
Note over Edit: Find target node by<br/>kind, name, line, index
Edit->>Edit: applyEdit(operation, node, content)
Note over Edit: Apply replace/insert/delete<br/>with indentation preservation
Edit->>Parser: Parse(ctx, path, newContent)
activate Parser
Parser-->>Edit: Validate syntax
deactivate Parser
alt Syntax Error
Edit-->>Server: ValidationError
Server-->>Client: Error: invalid syntax
end
else Text Mode (non-code files)
Edit->>Edit: resolveTextSelector(selector)
Note over Edit: Find by text, pattern,<br/>or line range
Edit->>Edit: applyTextEditOperation
end
Edit->>Edit: generateDiff(original, new)
Edit->>FS: Stat(path) - get permissions
Edit->>FS: WriteFile(path, content, perm)
Note over Edit,FS: Atomic write preserves<br/>original permissions
Edit->>Edit: unlockFile(path)
Edit-->>Server: EditResult{Success, Diff}
deactivate Edit
Server-->>Client: Success + Diff
deactivate Server
```
#### Parse and Cache Flow
The parser uses content-based caching for efficient AST reuse:
```mermaid
sequenceDiagram
participant Client as MCP Client
participant Server as MCP Server
participant Parser as Parser Registry
participant Cache as LRU Cache
participant TS as Tree-sitter
Client->>Server: file_read(path, include_ast=true)
activate Server
Server->>Parser: Parse(ctx, path, content)
activate Parser
Parser->>Parser: contentHash(content)
Note over Parser: xxHash64 for fast<br/>content fingerprinting
Parser->>Cache: Get(hash)
activate Cache
alt Cache Hit
Cache-->>Parser: CachedTree
Parser->>Parser: cacheHits++
Note over Parser: ~100x faster than parsing
else Cache Miss
Cache-->>Parser: nil
deactivate Cache
Parser->>Parser: cacheMisses++
Parser->>Parser: GetParser(language)
Note over Parser: One parser per language,<br/>reused across requests
Parser->>TS: ParseCtx(ctx, content)
activate TS
Note over TS: Tree-sitter parsing<br/>with timeout support
TS-->>Parser: *sitter.Tree
deactivate TS
Parser->>Cache: Add(hash, tree)
activate Cache
Note over Cache: LRU eviction when<br/>capacity reached (100 entries)
Cache-->>Parser: stored
deactivate Cache
end
Parser->>Parser: extractErrors(tree)
Parser->>Parser: ExtractSymbols(tree)
Parser-->>Server: ParseResult{Tree, Language, Errors, Symbols}
deactivate Parser
Server->>Server: generateASTSummary()
Server-->>Client: File content + Symbol summary
deactivate Server
```
#### Request Flow Summary
```mermaid
flowchart TB
subgraph "MCP Protocol Layer"
A[MCP Client] --> B[MCP Server]
end
subgraph "Tool Handlers"
B --> C{Tool Type}
C -->|Search| D[file_search]
C -->|Read| E[file_read]
C -->|Query| F[ast_query]
C -->|LSP| G[symbol_at<br/>find_definition<br/>find_references]
C -->|Edit| H[edit_apply]
end
subgraph "Core Engines"
D --> I[Search Engine<br/>ripgrep]
E --> J[Parser Registry]
F --> J
F --> K[Query Matcher]
G --> L[LSP Manager]
H --> M[Edit Engine]
M --> J
end
subgraph "External Systems"
I --> N[(File System)]
J --> O[Tree-sitter]
J --> P[(Parse Cache)]
L --> Q[gopls<br/>typescript-language-server<br/>pylsp<br/>clangd]
M --> N
end
```
## Troubleshooting
### Common Issues
@@ -545,7 +811,6 @@ The edit engine validates syntax before and after edits.
**Solution**:
- Ensure `new_content` is syntactically valid for the target language
- Use `edit_preview` first to see the proposed changes
- Check that the selector matches exactly one node
#### Timeout Errors
+352
View File
@@ -0,0 +1,352 @@
// Package main implements an API documentation generator for MCP tools.
package main
import (
"fmt"
"os"
"strings"
"time"
)
// ToolParameter represents a parameter for an MCP tool.
type ToolParameter struct {
Name string
Type string
Description string
Required bool
}
// Tool represents an MCP tool with its documentation.
type Tool struct {
Name string
Description string
Parameters []ToolParameter
Examples []string
Notes []string
ReadOnly bool
Category string
}
// AllTools returns the complete list of MCP tools with their documentation.
func AllTools() []Tool {
return []Tool{
{
Name: "ping",
Description: "Health check - returns pong to verify the server is running",
Category: "System",
ReadOnly: true,
Parameters: []ToolParameter{},
Examples: []string{
`{"tool": "ping"}`,
},
Notes: []string{
"Returns: \"pong\"",
"Use to verify server connectivity",
},
},
{
Name: "file_search",
Description: "Search for text patterns in files using ripgrep. Supports regex patterns, file type filtering, and context lines.",
Category: "Search",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "pattern", Type: "string", Required: true, Description: "The search pattern (regex by default)"},
{Name: "paths", Type: "array[string]", Required: false, Description: "Paths to search in (defaults to workspace root)"},
{Name: "file_types", Type: "array[string]", Required: false, Description: "File types to search (e.g., ['go', 'ts', 'py'])"},
{Name: "ignore_case", Type: "boolean", Required: false, Description: "Case insensitive search"},
{Name: "regex", Type: "boolean", Required: false, Description: "Treat pattern as regex (default: true)"},
{Name: "context_lines", Type: "number", Required: false, Description: "Number of context lines around matches (default: 2)"},
{Name: "max_results", Type: "number", Required: false, Description: "Maximum number of results to return"},
},
Examples: []string{
`{"pattern": "func.*Error", "file_types": ["go"]}`,
`{"pattern": "TODO", "ignore_case": true, "context_lines": 3}`,
},
Notes: []string{
"Requires ripgrep (rg) to be installed",
"Respects .gitignore by default",
},
},
{
Name: "file_read",
Description: "Read a file's contents with optional line range and AST symbol summary",
Category: "File Operations",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "path", Type: "string", Required: true, Description: "Path to the file to read"},
{Name: "line_start", Type: "number", Required: false, Description: "Starting line number (1-indexed)"},
{Name: "line_end", Type: "number", Required: false, Description: "Ending line number (inclusive)"},
{Name: "include_ast", Type: "boolean", Required: false, Description: "Include AST symbol summary (functions, classes, types, etc.)"},
{Name: "symbols_only", Type: "boolean", Required: false, Description: "Return only symbol summary without file content (token-efficient mode). Requires include_ast=true."},
{Name: "max_lines", Type: "number", Required: false, Description: "Maximum number of lines to return (for token efficiency). Applied after line_start/line_end."},
},
Examples: []string{
`{"path": "server.go", "include_ast": true}`,
`{"path": "server.go", "include_ast": true, "symbols_only": true}`,
`{"path": "server.go", "line_start": 10, "line_end": 50}`,
`{"path": "large_file.go", "max_lines": 100}`,
},
Notes: []string{
"symbols_only mode reduces token usage by ~90-98%",
"max_lines truncates output with notification",
"AST symbols show line numbers for quick navigation",
},
},
{
Name: "ast_query",
Description: "Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types.",
Category: "AST Operations",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "pattern", Type: "string", Required: true, Description: "Code pattern with placeholders: $NAME (single), $$$ARGS (multiple), $_ (wildcard). Examples: 'func $NAME($$$ARGS) error', 'class $NAME { $$$BODY }'"},
{Name: "language", Type: "string", Required: true, Description: "Target language: go, typescript, javascript, python, c, cpp"},
{Name: "paths", Type: "array[string]", Required: false, Description: "Paths to search in (defaults to workspace root)"},
{Name: "name_matches", Type: "string", Required: false, Description: "Regex pattern to filter by name"},
{Name: "name_exact", Type: "string", Required: false, Description: "Exact name to match"},
{Name: "kind_in", Type: "array[string]", Required: false, Description: "Node types to match (e.g., function_declaration, class_declaration)"},
{Name: "max_results", Type: "number", Required: false, Description: "Maximum number of results to return (default: 100)"},
},
Examples: []string{
`{"pattern": "func $NAME($$$ARGS) error", "language": "go"}`,
`{"pattern": "class $NAME: $$$BODY", "language": "python"}`,
`{"pattern": "function $NAME($PROPS) { $$$BODY }", "language": "javascript", "name_matches": "^[A-Z]"}`,
},
Notes: []string{
"$NAME captures identifiers",
"$$$ARGS captures multiple items (parameters, body, etc.)",
"$_ is a wildcard that matches but doesn't capture",
"Powered by Tree-sitter for accurate AST parsing",
},
},
{
Name: "symbol_at",
Description: "Get information about the symbol at a specific position in a file. Returns type, documentation, and definition location using LSP when available.",
Category: "LSP Operations",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "file", Type: "string", Required: true, Description: "Path to the file"},
{Name: "line", Type: "number", Required: true, Description: "Line number (1-indexed)"},
{Name: "column", Type: "number", Required: true, Description: "Column number (1-indexed)"},
},
Examples: []string{
`{"file": "server.go", "line": 25, "column": 10}`,
},
Notes: []string{
"Requires LSP server for full type information",
"Falls back to AST-based info if LSP unavailable",
},
},
{
Name: "find_definition",
Description: "Find the definition of the symbol at a specific position. Uses LSP to locate where a function, variable, type, etc. is defined.",
Category: "LSP Operations",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "file", Type: "string", Required: true, Description: "Path to the file"},
{Name: "line", Type: "number", Required: true, Description: "Line number (1-indexed)"},
{Name: "column", Type: "number", Required: true, Description: "Column number (1-indexed)"},
},
Examples: []string{
`{"file": "server.go", "line": 42, "column": 15}`,
},
Notes: []string{
"Requires language server for the file type",
"Returns file path, line, and column of definition",
"Shows code preview at definition location",
},
},
{
Name: "find_references",
Description: "Find all references to the symbol at a specific position. Uses LSP to locate all usages of a function, variable, type, etc.",
Category: "LSP Operations",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "file", Type: "string", Required: true, Description: "Path to the file"},
{Name: "line", Type: "number", Required: true, Description: "Line number (1-indexed)"},
{Name: "column", Type: "number", Required: true, Description: "Column number (1-indexed)"},
{Name: "include_declaration", Type: "boolean", Required: false, Description: "Include the declaration in results (default: true)"},
},
Examples: []string{
`{"file": "server.go", "line": 42, "column": 15}`,
`{"file": "server.go", "line": 42, "column": 15, "include_declaration": false}`,
},
Notes: []string{
"Requires language server for the file type",
"Results grouped by file",
},
},
{
Name: "edit_apply",
Description: "Apply an edit to a file. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++) with syntax validation, and text-based editing for other files (Markdown, JSON, YAML, config files, etc.).",
Category: "Edit Operations",
ReadOnly: false,
Parameters: []ToolParameter{
{Name: "file", Type: "string", Required: true, Description: "Path to the file to edit"},
{Name: "operation", Type: "string", Required: true, Description: "Edit operation: replace, insert_before, insert_after, delete"},
{Name: "new_content", Type: "string", Required: false, Description: "New content (required for replace/insert operations)"},
{Name: "selector_kind", Type: "string", Required: false, Description: "AST node type to match (e.g., function_declaration, class_declaration). For code files only."},
{Name: "selector_name", Type: "string", Required: false, Description: "Name of the symbol to match. For code files only."},
{Name: "selector_line", Type: "number", Required: false, Description: "Line number (1-indexed). For AST mode: narrows search. For text mode: start of line range."},
{Name: "selector_index", Type: "number", Required: false, Description: "Index of the match to use if multiple matches found (default: 0)"},
{Name: "selector_line_end", Type: "number", Required: false, Description: "End line number for range selection (text mode). Used with selector_line."},
{Name: "selector_text", Type: "string", Required: false, Description: "Exact text to match (text mode). Must be unique or use selector_index."},
{Name: "selector_pattern", Type: "string", Required: false, Description: "Regex pattern to match (text mode). Must be unique or use selector_index."},
},
Examples: []string{
`{"file": "server.go", "operation": "replace", "selector_kind": "function_declaration", "selector_name": "Hello", "new_content": "func Hello() {\\n\\tprintln(\\"Updated\\")\\n}"}`,
`{"file": "config.yaml", "operation": "replace", "selector_line": 5, "selector_line_end": 10, "new_content": "database:\\n host: production.db.example.com\\n port: 5432"}`,
},
Notes: []string{
"For code files: validates syntax before and after edit",
"Preserves file permissions",
"Uses atomic writes for safety",
"File locking prevents concurrent edits",
},
},
}
}
// GenerateMarkdown generates the complete API documentation in Markdown format.
func GenerateMarkdown() string {
var sb strings.Builder
sb.WriteString("# MCP Filepuff API Reference\n\n")
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")
// Table of Contents
sb.WriteString("## Table of Contents\n\n")
tools := AllTools()
categories := make(map[string][]Tool)
categoryOrder := []string{}
for _, tool := range tools {
if _, ok := categories[tool.Category]; !ok {
categoryOrder = append(categoryOrder, tool.Category)
}
categories[tool.Category] = append(categories[tool.Category], tool)
}
for _, cat := range categoryOrder {
fmt.Fprintf(&sb, "### %s\n", cat)
for _, tool := range categories[cat] {
fmt.Fprintf(&sb, "- [`%s`](#%s)\n", tool.Name, tool.Name)
}
sb.WriteString("\n")
}
// Tool Documentation
sb.WriteString("---\n\n")
sb.WriteString("## Tool Reference\n\n")
for _, cat := range categoryOrder {
fmt.Fprintf(&sb, "### %s\n\n", cat)
for _, tool := range categories[cat] {
fmt.Fprintf(&sb, "#### `%s`\n\n", tool.Name)
fmt.Fprintf(&sb, "%s\n\n", tool.Description)
// Read-only indicator
if tool.ReadOnly {
sb.WriteString("🔒 **Read-only**: This tool does not modify files.\n\n")
} else {
sb.WriteString("⚠️ **Modifies files**: This tool writes to the filesystem.\n\n")
}
// Parameters
if len(tool.Parameters) > 0 {
sb.WriteString("**Parameters:**\n\n")
sb.WriteString("| Name | Type | Required | Description |\n")
sb.WriteString("|------|------|----------|-------------|\n")
for _, param := range tool.Parameters {
required := "No"
if param.Required {
required = "**Yes**"
}
fmt.Fprintf(&sb, "| `%s` | `%s` | %s | %s |\n",
param.Name, param.Type, required, param.Description)
}
sb.WriteString("\n")
} else {
sb.WriteString("**Parameters:** None\n\n")
}
// Examples
if len(tool.Examples) > 0 {
sb.WriteString("**Examples:**\n\n")
for _, example := range tool.Examples {
sb.WriteString("```json\n")
sb.WriteString(example)
sb.WriteString("\n```\n\n")
}
}
// Notes
if len(tool.Notes) > 0 {
sb.WriteString("**Notes:**\n\n")
for _, note := range tool.Notes {
fmt.Fprintf(&sb, "- %s\n", note)
}
sb.WriteString("\n")
}
sb.WriteString("---\n\n")
}
}
// Additional sections
sb.WriteString("## Supported Languages\n\n")
sb.WriteString("| Language | Extensions | Search | AST | LSP | Edit |\n")
sb.WriteString("|----------|-----------|--------|-----|-----|------|\n")
sb.WriteString("| Go | .go | ✅ | ✅ | gopls | ✅ |\n")
sb.WriteString("| TypeScript | .ts, .tsx | ✅ | ✅ | typescript-language-server | ✅ |\n")
sb.WriteString("| JavaScript | .js, .jsx, .mjs, .cjs | ✅ | ✅ | typescript-language-server | ✅ |\n")
sb.WriteString("| Python | .py, .pyw | ✅ | ✅ | pylsp | ✅ |\n")
sb.WriteString("| C | .c, .h | ✅ | ✅ | clangd | ✅ |\n")
sb.WriteString("| C++ | .cpp, .cc, .cxx, .hpp, .hxx | ✅ | ✅ | clangd | ✅ |\n")
sb.WriteString("| HTML | .html, .htm | ✅ | ✅ | - | ✅ |\n")
sb.WriteString("| Vue | .vue | ✅ | ✅* | - | ✅ |\n")
sb.WriteString("| React | .jsx, .tsx | ✅ | ✅ | typescript-language-server | ✅ |\n")
sb.WriteString("| Elixir | .ex, .exs | ✅ | ✅ | elixir-ls | ✅ |\n")
sb.WriteString("| JSON | .json | ✅ | ✅ | - | ✅ |\n")
sb.WriteString("| YAML | .yaml, .yml | ✅ | ✅ | - | ✅ |\n")
sb.WriteString("\n\\* Vue uses HTML parser for template sections\n\n")
sb.WriteString("## Error Handling\n\n")
sb.WriteString("All tools return structured errors with:\n")
sb.WriteString("- **Error code**: Numeric identifier for the error type\n")
sb.WriteString("- **Message**: Human-readable error description\n")
sb.WriteString("- **Context**: Additional information about the error\n")
sb.WriteString("- **Remediation**: Suggested fix for the error\n\n")
sb.WriteString("See [ERROR_CODES.md](ERROR_CODES.md) for a complete error reference.\n\n")
sb.WriteString("## See Also\n\n")
sb.WriteString("- [README.md](../README.md) - Project overview and installation\n")
sb.WriteString("- [ERROR_CODES.md](ERROR_CODES.md) - Error code reference\n")
sb.WriteString("- [PERFORMANCE.md](PERFORMANCE.md) - Performance tuning guide\n")
return sb.String()
}
func main() {
output := GenerateMarkdown()
// Default output path
outputPath := "docs/API.md"
if len(os.Args) > 1 {
outputPath = os.Args[1]
}
// Ensure docs directory exists
if err := os.MkdirAll("docs", 0o755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating docs directory: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(outputPath, []byte(output), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
os.Exit(1)
}
fmt.Printf("API documentation generated: %s\n", outputPath)
}
+13 -1
View File
@@ -67,9 +67,11 @@ func setupLogger(level string, logFile string) *slog.Logger {
}
var handler slog.Handler
var logFileErr error
if logFile != "" {
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
logFileErr = err
// Fallback to stderr
handler = slog.NewJSONHandler(os.Stderr, opts)
} else {
@@ -80,5 +82,15 @@ func setupLogger(level string, logFile string) *slog.Logger {
handler = slog.NewJSONHandler(os.Stderr, opts)
}
return slog.New(handler)
logger := slog.New(handler)
// Warn if log file couldn't be opened
if logFileErr != nil {
logger.Warn("failed to open log file, using stderr",
"file", logFile,
"error", logFileErr,
)
}
return logger
}
+1688 -1342
View File
File diff suppressed because it is too large Load Diff
+345
View File
@@ -0,0 +1,345 @@
# MCP Filepuff API Reference
> Auto-generated on 2026-03-12
This document provides detailed API documentation for all MCP tools available in filepuff.
## Table of Contents
### System
- [`ping`](#ping)
### Search
- [`file_search`](#file_search)
### File Operations
- [`file_read`](#file_read)
### AST Operations
- [`ast_query`](#ast_query)
### LSP Operations
- [`symbol_at`](#symbol_at)
- [`find_definition`](#find_definition)
- [`find_references`](#find_references)
### Edit Operations
- [`edit_apply`](#edit_apply)
---
## Tool Reference
### System
#### `ping`
Health check - returns pong to verify the server is running
🔒 **Read-only**: This tool does not modify files.
**Parameters:** None
**Examples:**
```json
{"tool": "ping"}
```
**Notes:**
- Returns: "pong"
- Use to verify server connectivity
---
### Search
#### `file_search`
Search for text patterns in files using ripgrep. Supports regex patterns, file type filtering, and context lines.
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `pattern` | `string` | **Yes** | The search pattern (regex by default) |
| `paths` | `array[string]` | No | Paths to search in (defaults to workspace root) |
| `file_types` | `array[string]` | No | File types to search (e.g., ['go', 'ts', 'py']) |
| `ignore_case` | `boolean` | No | Case insensitive search |
| `regex` | `boolean` | No | Treat pattern as regex (default: true) |
| `context_lines` | `number` | No | Number of context lines around matches (default: 2) |
| `max_results` | `number` | No | Maximum number of results to return |
**Examples:**
```json
{"pattern": "func.*Error", "file_types": ["go"]}
```
```json
{"pattern": "TODO", "ignore_case": true, "context_lines": 3}
```
**Notes:**
- Requires ripgrep (rg) to be installed
- Respects .gitignore by default
---
### File Operations
#### `file_read`
Read a file's contents with optional line range and AST symbol summary
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `path` | `string` | **Yes** | Path to the file to read |
| `line_start` | `number` | No | Starting line number (1-indexed) |
| `line_end` | `number` | No | Ending line number (inclusive) |
| `include_ast` | `boolean` | No | Include AST symbol summary (functions, classes, types, etc.) |
| `symbols_only` | `boolean` | No | Return only symbol summary without file content (token-efficient mode). Requires include_ast=true. |
| `max_lines` | `number` | No | Maximum number of lines to return (for token efficiency). Applied after line_start/line_end. |
**Examples:**
```json
{"path": "server.go", "include_ast": true}
```
```json
{"path": "server.go", "include_ast": true, "symbols_only": true}
```
```json
{"path": "server.go", "line_start": 10, "line_end": 50}
```
```json
{"path": "large_file.go", "max_lines": 100}
```
**Notes:**
- symbols_only mode reduces token usage by ~90-98%
- max_lines truncates output with notification
- AST symbols show line numbers for quick navigation
---
### AST Operations
#### `ast_query`
Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types.
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `pattern` | `string` | **Yes** | Code pattern with placeholders: $NAME (single), $$$ARGS (multiple), $_ (wildcard). Examples: 'func $NAME($$$ARGS) error', 'class $NAME { $$$BODY }' |
| `language` | `string` | **Yes** | Target language: go, typescript, javascript, python, c, cpp |
| `paths` | `array[string]` | No | Paths to search in (defaults to workspace root) |
| `name_matches` | `string` | No | Regex pattern to filter by name |
| `name_exact` | `string` | No | Exact name to match |
| `kind_in` | `array[string]` | No | Node types to match (e.g., function_declaration, class_declaration) |
| `max_results` | `number` | No | Maximum number of results to return (default: 100) |
**Examples:**
```json
{"pattern": "func $NAME($$$ARGS) error", "language": "go"}
```
```json
{"pattern": "class $NAME: $$$BODY", "language": "python"}
```
```json
{"pattern": "function $NAME($PROPS) { $$$BODY }", "language": "javascript", "name_matches": "^[A-Z]"}
```
**Notes:**
- $NAME captures identifiers
- $$$ARGS captures multiple items (parameters, body, etc.)
- $_ is a wildcard that matches but doesn't capture
- Powered by Tree-sitter for accurate AST parsing
---
### LSP Operations
#### `symbol_at`
Get information about the symbol at a specific position in a file. Returns type, documentation, and definition location using LSP when available.
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `file` | `string` | **Yes** | Path to the file |
| `line` | `number` | **Yes** | Line number (1-indexed) |
| `column` | `number` | **Yes** | Column number (1-indexed) |
**Examples:**
```json
{"file": "server.go", "line": 25, "column": 10}
```
**Notes:**
- Requires LSP server for full type information
- Falls back to AST-based info if LSP unavailable
---
#### `find_definition`
Find the definition of the symbol at a specific position. Uses LSP to locate where a function, variable, type, etc. is defined.
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `file` | `string` | **Yes** | Path to the file |
| `line` | `number` | **Yes** | Line number (1-indexed) |
| `column` | `number` | **Yes** | Column number (1-indexed) |
**Examples:**
```json
{"file": "server.go", "line": 42, "column": 15}
```
**Notes:**
- Requires language server for the file type
- Returns file path, line, and column of definition
- Shows code preview at definition location
---
#### `find_references`
Find all references to the symbol at a specific position. Uses LSP to locate all usages of a function, variable, type, etc.
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `file` | `string` | **Yes** | Path to the file |
| `line` | `number` | **Yes** | Line number (1-indexed) |
| `column` | `number` | **Yes** | Column number (1-indexed) |
| `include_declaration` | `boolean` | No | Include the declaration in results (default: true) |
**Examples:**
```json
{"file": "server.go", "line": 42, "column": 15}
```
```json
{"file": "server.go", "line": 42, "column": 15, "include_declaration": false}
```
**Notes:**
- Requires language server for the file type
- Results grouped by file
---
### Edit Operations
#### `edit_apply`
Apply an edit to a file. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++) with syntax validation, and text-based editing for other files (Markdown, JSON, YAML, config files, etc.).
⚠️ **Modifies files**: This tool writes to the filesystem.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `file` | `string` | **Yes** | Path to the file to edit |
| `operation` | `string` | **Yes** | Edit operation: replace, insert_before, insert_after, delete |
| `new_content` | `string` | No | New content (required for replace/insert operations) |
| `selector_kind` | `string` | No | AST node type to match (e.g., function_declaration, class_declaration). For code files only. |
| `selector_name` | `string` | No | Name of the symbol to match. For code files only. |
| `selector_line` | `number` | No | Line number (1-indexed). For AST mode: narrows search. For text mode: start of line range. |
| `selector_index` | `number` | No | Index of the match to use if multiple matches found (default: 0) |
| `selector_line_end` | `number` | No | End line number for range selection (text mode). Used with selector_line. |
| `selector_text` | `string` | No | Exact text to match (text mode). Must be unique or use selector_index. |
| `selector_pattern` | `string` | No | Regex pattern to match (text mode). Must be unique or use selector_index. |
**Examples:**
```json
{"file": "server.go", "operation": "replace", "selector_kind": "function_declaration", "selector_name": "Hello", "new_content": "func Hello() {\\n\\tprintln(\\"Updated\\")\\n}"}
```
```json
{"file": "config.yaml", "operation": "replace", "selector_line": 5, "selector_line_end": 10, "new_content": "database:\\n host: production.db.example.com\\n port: 5432"}
```
**Notes:**
- For code files: validates syntax before and after edit
- Preserves file permissions
- Uses atomic writes for safety
- File locking prevents concurrent edits
---
## Supported Languages
| Language | Extensions | Search | AST | LSP | Edit |
|----------|-----------|--------|-----|-----|------|
| Go | .go | ✅ | ✅ | gopls | ✅ |
| TypeScript | .ts, .tsx | ✅ | ✅ | typescript-language-server | ✅ |
| JavaScript | .js, .jsx, .mjs, .cjs | ✅ | ✅ | typescript-language-server | ✅ |
| Python | .py, .pyw | ✅ | ✅ | pylsp | ✅ |
| C | .c, .h | ✅ | ✅ | clangd | ✅ |
| C++ | .cpp, .cc, .cxx, .hpp, .hxx | ✅ | ✅ | clangd | ✅ |
| HTML | .html, .htm | ✅ | ✅ | - | ✅ |
| Vue | .vue | ✅ | ✅* | - | ✅ |
| React | .jsx, .tsx | ✅ | ✅ | typescript-language-server | ✅ |
| Elixir | .ex, .exs | ✅ | ✅ | elixir-ls | ✅ |
| JSON | .json | ✅ | ✅ | - | ✅ |
| YAML | .yaml, .yml | ✅ | ✅ | - | ✅ |
\* Vue uses HTML parser for template sections
## Error Handling
All tools return structured errors with:
- **Error code**: Numeric identifier for the error type
- **Message**: Human-readable error description
- **Context**: Additional information about the error
- **Remediation**: Suggested fix for the error
See [ERROR_CODES.md](ERROR_CODES.md) for a complete error reference.
## See Also
- [README.md](../README.md) - Project overview and installation
- [ERROR_CODES.md](ERROR_CODES.md) - Error code reference
- [PERFORMANCE.md](PERFORMANCE.md) - Performance tuning guide
+653
View File
@@ -0,0 +1,653 @@
# MCP Filepuff Error Codes Reference
This document provides a comprehensive reference for all error codes that can be returned by mcp-filepuff, along with descriptions and remediation steps.
## Error Code Structure
All errors in mcp-filepuff use structured error handling with the following components:
- **Error Code**: A numeric identifier (1000-1999) for programmatic handling
- **Message**: Human-readable description of the error
- **Context**: Additional key-value pairs providing error context
- **Remediation**: Suggested steps to resolve the error
- **Cause**: Underlying error (if wrapping another error)
## Error Categories
| Range | Category | Description |
|-------|----------|-------------|
| 1000-1099 | Search Errors | File search and ripgrep operations |
| 1100-1199 | Parser Errors | AST parsing and tree-sitter operations |
| 1200-1299 | LSP Errors | Language server protocol operations |
| 1300-1399 | Edit Errors | File editing operations |
| 1400-1499 | Query Errors | AST pattern matching operations |
| 1500-1599 | Config Errors | Configuration and validation |
| 1900-1999 | Internal Errors | Internal server errors |
---
## Search Errors (1000-1099)
### 1001 - ErrRipgrepNotFound
**Description**: The ripgrep (`rg`) binary is not found in the system PATH.
**Common Causes**:
- ripgrep is not installed
- ripgrep is not in PATH
- PATH environment variable not properly set
**Remediation**:
```bash
# macOS
brew install ripgrep
# Ubuntu/Debian
sudo apt install ripgrep
# Windows (Chocolatey)
choco install ripgrep
# Windows (Scoop)
scoop install ripgrep
```
---
### 1002 - ErrRipgrepTimeout
**Description**: Search operation exceeded the configured timeout limit.
**Context Fields**:
- `pattern`: The search pattern that timed out
- `duration`: How long the search ran before timing out
**Common Causes**:
- Search pattern too broad (e.g., `.` matching everything)
- Searching a very large directory tree
- Complex regex pattern causing backtracking
**Remediation**:
1. Use more specific search patterns
2. Narrow the search scope with `paths` parameter
3. Increase timeout via `MCP_SEARCH_TIMEOUT` environment variable:
```bash
export MCP_SEARCH_TIMEOUT="2m"
```
---
### 1003 - ErrInvalidPattern
**Description**: The search pattern is invalid or malformed.
**Context Fields**:
- `pattern`: The invalid pattern
- `error`: Specific parsing error
**Common Causes**:
- Invalid regex syntax
- Unclosed brackets or parentheses
- Invalid escape sequences
**Remediation**:
1. Validate regex syntax at [regex101.com](https://regex101.com)
2. Escape special characters properly
3. Use `regex: false` for literal string searches
---
### 1004 - ErrSearchFailed
**Description**: General search operation failure.
**Context Fields**:
- `error`: Underlying error message
**Common Causes**:
- Permission denied on files/directories
- I/O errors
- ripgrep process crashed
**Remediation**:
1. Check file and directory permissions
2. Verify workspace path is accessible
3. Check system logs for I/O errors
---
### 1005 - ErrNoResults
**Description**: Search completed but found no matches.
**Note**: This is an informational code, not necessarily an error.
---
## Parser Errors (1100-1199)
### 1101 - ErrParserNotFound
**Description**: No Tree-sitter parser is available for the requested language.
**Context Fields**:
- `language`: The unsupported language
**Supported Languages**:
- Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir, JSON, YAML
**Remediation**:
1. Use a supported language
2. Check file extension is correct
---
### 1102 - ErrParseFailed
**Description**: Tree-sitter failed to parse the file.
**Context Fields**:
- `file`: Path to the file
- `language`: Detected language
- `error`: Specific parsing error
**Common Causes**:
- File has syntax errors
- File encoding is not UTF-8
- Binary file detected
**Remediation**:
1. Fix syntax errors in the source file
2. Ensure file is valid UTF-8
3. Check file is not a binary
---
### 1103 - ErrInvalidLanguage
**Description**: The specified or detected language is not supported.
**Context Fields**:
- `language`: The unsupported language
- `file`: File path (if detected from extension)
**Remediation**:
- Use a file with a supported extension
- Supported: `.go`, `.ts`, `.tsx`, `.js`, `.jsx`, `.py`, `.c`, `.h`, `.cpp`, `.hpp`, `.html`, `.vue`, `.ex`, `.exs`, `.json`, `.yaml`, `.yml`
---
### 1104 - ErrFileTooBig
**Description**: File exceeds the maximum size limit for parsing.
**Context Fields**:
- `file`: Path to the file
- `size_bytes`: Actual file size
- `limit_bytes`: Maximum allowed size
**Default Limit**: 10 MB
**Remediation**:
1. Process smaller files
2. Increase limit via configuration:
```json
{
"max_parse_size": 20971520
}
```
---
### 1105 - ErrInvalidSyntax
**Description**: File contains syntax errors that prevent proper parsing.
**Context Fields**:
- `file`: Path to the file
- `errors`: List of syntax errors with locations
**Remediation**:
1. Fix syntax errors in the source file
2. Run language-specific linters/compilers to identify issues
---
## LSP Errors (1200-1299)
### 1201 - ErrLSPServerNotFound
**Description**: The LSP server for the requested language is not installed or not in PATH.
**Context Fields**:
- `language`: The language needing LSP
- `server`: The expected server binary name
**LSP Servers by Language**:
| Language | Server | Installation |
|----------|--------|--------------|
| Go | `gopls` | `go install golang.org/x/tools/gopls@latest` |
| TypeScript/JavaScript | `typescript-language-server` | `npm install -g typescript-language-server typescript` |
| Python | `pylsp` | `pip install python-lsp-server` |
| C/C++ | `clangd` | Install via system package manager |
**Remediation**:
Install the appropriate language server for your language.
---
### 1202 - ErrLSPInitFailed
**Description**: LSP server failed to initialize.
**Context Fields**:
- `language`: The language
- `command`: The server command
- `error`: Specific initialization error
**Common Causes**:
- Workspace configuration issues
- Missing dependencies for the project
- LSP server crashed during startup
**Remediation**:
1. Check LSP server logs
2. Verify workspace has proper configuration files (go.mod, package.json, etc.)
3. Try running the LSP server manually to see errors
---
### 1203 - ErrLSPTimeout
**Description**: LSP operation exceeded timeout.
**Context Fields**:
- `operation`: The LSP method that timed out
- `duration`: How long the operation ran
**Common Causes**:
- LSP server indexing large project
- Complex type analysis
- Server deadlock
**Remediation**:
1. Wait for LSP server to complete initial indexing
2. Increase timeout via `MCP_LSP_TIMEOUT`:
```bash
export MCP_LSP_TIMEOUT="10m"
```
3. Restart the MCP server to restart LSP servers
---
### 1204 - ErrLSPCommunication
**Description**: Communication error with LSP server.
**Context Fields**:
- `error`: Specific communication error
**Common Causes**:
- LSP server crashed
- Broken pipe (server terminated)
- Protocol version mismatch
**Remediation**:
1. Restart the MCP server
2. Update LSP server to latest version
3. Check for known issues with your LSP server version
---
### 1205 - ErrNoHoverInfo
**Description**: No hover information available at the requested position.
**Note**: Not all positions have hover information (e.g., whitespace, keywords).
---
### 1206 - ErrNoDefinition
**Description**: No definition found for the symbol at the requested position.
**Common Causes**:
- Position is not on a symbol
- Symbol is a built-in
- External dependency without source
---
### 1207 - ErrNoReferences
**Description**: No references found for the symbol.
**Note**: A symbol may have no references if unused.
---
## Edit Errors (1300-1399)
### 1301 - ErrEditFailed
**Description**: General edit operation failure.
**Context Fields**:
- `file`: Path to the file
- `operation`: The edit operation attempted
- `error`: Specific error details
---
### 1302 - ErrInvalidEdit
**Description**: The edit request is malformed or invalid.
**Context Fields**:
- `message`: Specific validation failure
**Common Causes**:
- Missing required fields
- Invalid operation type
- Missing `new_content` for replace/insert
**Valid Operations**:
- `replace` - Replace selected content
- `insert_before` - Insert before selection
- `insert_after` - Insert after selection
- `delete` - Delete selected content
**Remediation**:
Review the edit request and ensure all required fields are provided.
---
### 1303 - ErrFileNotFound
**Description**: The target file does not exist.
**Context Fields**:
- `file`: Path that was not found
**Remediation**:
1. Verify the file path is correct
2. Check for typos in the path
3. Ensure the file exists in the workspace
---
### 1304 - ErrFileNotReadable
**Description**: The file exists but cannot be read.
**Context Fields**:
- `file`: Path to the file
- `error`: System error details
**Common Causes**:
- Permission denied
- File locked by another process
- File is a directory
**Remediation**:
1. Check file permissions
2. Close other programs using the file
3. On macOS, ensure terminal has disk access
---
### 1305 - ErrFileNotWritable
**Description**: Cannot write to the file.
**Context Fields**:
- `file`: Path to the file
- `error`: System error details
**Common Causes**:
- Permission denied
- Read-only filesystem
- Disk full
- File locked
**Remediation**:
1. Check file and directory permissions
2. Verify disk space
3. Close other programs using the file
---
### 1306 - ErrNodeNotFound
**Description**: No AST node matches the selector criteria.
**Context Fields**:
- `selector`: Description of the selector criteria
**Common Causes**:
- Selector doesn't match any code
- Wrong node kind specified
- Name doesn't exist in file
**Remediation**:
1. Use `file_read` with `include_ast: true` to see available symbols
2. Verify selector criteria (kind, name, pattern, line)
3. Check spelling of symbol names
---
### 1307 - ErrValidationFailed
**Description**: Edit would produce invalid syntax.
**Context Fields**:
- `file`: Path to the file
- `error`: Syntax error details
**Common Causes**:
- `new_content` has syntax errors
- Edit breaks surrounding code structure
**Remediation**:
1. Validate `new_content` syntax independently
2. Ensure the edit maintains valid syntax
---
### 1308 - ErrInvalidSelection
**Description**: Selector matches multiple nodes or is ambiguous.
**Context Fields**:
- `message`: Details about the ambiguity
**Common Causes**:
- Text/pattern matches multiple locations
- Multiple functions with same name
**Remediation**:
1. Add `selector_index` to choose specific match
2. Add more selector criteria (kind, name, line)
3. Use line number to narrow selection
---
## Query Errors (1400-1499)
### 1401 - ErrInvalidQuery
**Description**: The AST query is malformed.
**Context Fields**:
- `query`: The invalid query
- `error`: Specific parsing error
**Remediation**:
Review query syntax. Valid patterns use:
- `$NAME` for single captures
- `$$$ARGS` for multiple items
- `$_` for wildcards
---
### 1402 - ErrQueryTimeout
**Description**: AST query exceeded timeout.
**Context Fields**:
- `query`: The query that timed out
- `duration`: How long it ran
**Remediation**:
1. Use more specific patterns
2. Narrow search paths
3. Reduce `max_results`
---
### 1403 - ErrNoMatches
**Description**: Query executed successfully but found no matches.
**Note**: Informational, not necessarily an error.
---
### 1404 - ErrQueryCompile
**Description**: Failed to compile the query pattern.
**Context Fields**:
- `pattern`: The invalid pattern
- `error`: Compilation error
---
## Config Errors (1500-1599)
### 1501 - ErrInvalidConfig
**Description**: Configuration file or value is invalid.
**Context Fields**:
- `field`: The invalid field
- `value`: The invalid value
- `error`: Specific validation error
**Remediation**:
Review `.mcp-filepuff.json` configuration file syntax and values.
---
### 1502 - ErrPathNotAllowed
**Description**: Requested path is outside the workspace root.
**Context Fields**:
- `path`: The requested path
- `workspace`: The workspace root
**Security Note**: This prevents path traversal attacks.
**Remediation**:
1. Ensure all paths are within workspace
2. Don't use `..` to escape workspace
3. Configure workspace root appropriately
---
### 1503 - ErrWorkspaceNotSet
**Description**: Workspace root is not configured.
**Remediation**:
Start the server with `-workspace` flag:
```bash
mcp-filepuff -workspace /path/to/workspace
```
---
## Internal Errors (1900-1999)
### 1900 - ErrInternal
**Description**: Unexpected internal error.
**Context Fields**:
- `error`: Error details
- `stack`: Stack trace (in debug mode)
**Remediation**:
1. Check server logs
2. Report issue if reproducible
---
### 1901 - ErrCacheFailed
**Description**: Cache operation failed.
**Context Fields**:
- `operation`: Cache operation that failed
- `error`: Specific error
---
### 1902 - ErrConcurrency
**Description**: Concurrency-related error (race condition, deadlock).
**Context Fields**:
- `operation`: Operation that failed
- `error`: Specific error
---
## Common Error Scenarios
### Scenario 1: New Project Setup
**Symptoms**: LSP features not working, errors like "LSP server not found"
**Solution**:
1. Install required language servers
2. Initialize project (e.g., `go mod init`, `npm init`)
3. Restart MCP server
### Scenario 2: Edit Fails with Validation Error
**Symptoms**: Edit operation rejected with syntax error
**Solution**:
1. Validate `new_content` is syntactically correct
2. Check that surrounding code structure is maintained
### Scenario 3: Search Returns Too Many Results
**Symptoms**: Search timeout or truncated results
**Solution**:
1. Use more specific patterns
2. Add file type filters: `"file_types": ["go"]`
3. Limit search paths
4. Set `max_results` limit
### Scenario 4: File Access Errors
**Symptoms**: Permission denied, file not readable
**Solution**:
1. Check file permissions: `ls -la <file>`
2. On macOS, grant disk access to terminal
3. Close programs locking the file
4. Run server with appropriate user permissions
---
## See Also
- [API.md](API.md) - Complete API reference
- [PERFORMANCE.md](PERFORMANCE.md) - Performance tuning guide
- [README.md](../README.md) - Getting started
+404
View File
@@ -0,0 +1,404 @@
# MCP Filepuff Performance Tuning Guide
This guide provides detailed information on optimizing mcp-filepuff performance, understanding resource usage, and configuring the server for your workload.
## Table of Contents
- [Parser Cache Configuration](#parser-cache-configuration)
- [File Size Limits](#file-size-limits)
- [LSP Configuration](#lsp-configuration)
- [Memory Usage Patterns](#memory-usage-patterns)
- [Benchmarking](#benchmarking)
- [Production Recommendations](#production-recommendations)
---
## Parser Cache Configuration
The parser cache is critical for performance as it avoids re-parsing files that haven't changed.
### How the Cache Works
```
┌─────────────────────────────────────────────────────────────┐
│ Parse Request │
│ (file, content) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────┐
│ Content Hash Check │
│ (xxHash64) │
└──────────┬───────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
Cache Hit Cache Miss
│ │
▼ ▼
Return cached tree Parse with Tree-sitter
│ │
│ ▼
│ Store in LRU cache
│ │
└───────────┬───────────┘
│
▼
Return ParseResult
```
### Cache Statistics
The parser tracks detailed statistics:
```go
type CacheStatsResult struct {
Hits int64 // Number of cache hits
Misses int64 // Number of cache misses
HitRate float64 // Ratio of hits to total requests
Size int // Current number of cached items
TotalParseTime int64 // Total time spent parsing (nanoseconds)
ParseCount int64 // Number of parse operations
AvgParseTime int64 // Average parse time (nanoseconds)
LastParseTime int64 // Most recent parse duration
}
```
### Cache Configuration
The LRU cache holds up to **100 parsed AST trees** by default. This is sufficient for most development workflows where you interact with a subset of files.
**Cache Key**: xxHash64 of file content (extremely fast, ~5GB/s)
**Eviction Policy**: Least Recently Used (LRU) - when the cache is full, the least recently accessed entry is evicted.
### Optimizing Cache Performance
1. **Batch Related Operations**: When working on related files, perform all operations on one file before moving to the next. This maximizes cache hits.
2. **Monitor Hit Rate**: A healthy cache has >80% hit rate. Lower rates suggest:
- Working with too many files simultaneously
- Files changing frequently between operations
3. **Cache Invalidation**: The cache is content-based (hash), so modified files automatically get re-parsed.
---
## File Size Limits
### Default Limits
| Limit | Default Value | Environment Variable |
|-------|---------------|---------------------|
| Max File Size | 10 MB | - |
| Max Parse Size | 10 MB | - |
| Max Edit Size | 100 KB | - |
| Max Search Results | 1000 | - |
### Configuration
Configure via `.mcp-filepuff.json` in workspace root:
```json
{
"max_file_size": 10485760,
"max_parse_size": 10485760,
"max_search_results": 1000,
"max_edit_size": 102400
}
```
### Understanding Limits
**Max File Size (10 MB)**
- Maximum file size that can be read via `file_read`
- Prevents memory exhaustion with large files
- Increase for codebases with large generated files
**Max Parse Size (10 MB)**
- Maximum file size for AST parsing
- Tree-sitter parsing memory usage is ~3-5x file size
- A 10 MB file needs ~30-50 MB RAM for parsing
**Max Edit Size (100 KB)**
- Maximum size for files being edited
- Keeps diff generation fast
- Prevents accidental edits to large generated files
### Token-Efficient Reading
For large files, use token-efficient options:
```json
// Get only symbol summary (~90-98% token reduction)
{"path": "large_file.go", "include_ast": true, "symbols_only": true}
// Limit output lines
{"path": "large_file.go", "max_lines": 50}
// Read specific line range
{"path": "large_file.go", "line_start": 100, "line_end": 150}
```
---
## LSP Configuration
### Timeout Configuration
```bash
# LSP operation timeout (default: 5 minutes)
export MCP_LSP_TIMEOUT="5m"
# Search timeout (default: 30 seconds)
export MCP_SEARCH_TIMEOUT="30s"
```
### LSP Server Lifecycle
```
┌─────────────────────────────────────────────────────────────┐
│ LSP Request │
│ (hover, definition, references) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────┐
│ Check Server Pool │
│ (by language) │
└──────────┬───────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
Server Exists No Server
│ │
▼ ▼
Update lastUsed Start New Server
│ │
│ ▼
│ Initialize (handshake)
│ │
└───────────┬───────────┘
│
▼
┌─────────────────┐
│ Open Document │
│ (if not open) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Execute LSP │
│ Request │
└────────┬────────┘
│
▼
Return Result
```
### Server Pool Management
- **Idle Timeout**: 5 minutes (servers closed after inactivity)
- **Pool Reaper**: Checks every 60 seconds for idle servers
- **One Server Per Language**: Efficient resource usage
### Optimizing LSP Performance
1. **First Request Latency**: Initial LSP requests are slow due to server startup and project indexing. Subsequent requests are fast.
2. **gopls Optimization**: For Go projects, gopls performance depends on module cache:
```bash
# Pre-populate module cache
go mod download
```
3. **typescript-language-server**: Ensure `node_modules` is populated:
```bash
npm install
```
4. **clangd**: Requires `compile_commands.json` for best results:
```bash
# Generate with CMake
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON .
```
---
## Memory Usage Patterns
### Component Memory Usage
| Component | Memory Pattern | Notes |
|-----------|---------------|-------|
| Parser Registry | Per-language parsers | ~5-10 MB per language |
| AST Cache | LRU, 100 entries max | ~50-200 MB typically |
| LSP Servers | External processes | ~100-500 MB per server |
| Search (ripgrep) | Streaming | Minimal memory |
| Edit Engine | Per-operation | Proportional to file size |
### Memory Calculation Example
For a typical Go project:
```
Base Server: ~20 MB
Go Parser: ~10 MB
AST Cache (50 files): ~100 MB
gopls: ~300 MB
────────────────────────────────
Total: ~430 MB
```
### Reducing Memory Usage
1. **Disable LSP**: If you don't need go-to-definition/references:
```bash
export MCP_ENABLE_LSP="false"
```
This saves ~100-500 MB per language server.
2. **Reduce Cache Size**: For memory-constrained environments, you can recompile with a smaller cache size (requires code change).
3. **Close Idle Servers**: LSP servers are automatically closed after 5 minutes of inactivity.
---
## Benchmarking
### Running Benchmarks
The project includes comprehensive benchmarks:
```bash
# Run all benchmarks
go test -bench=. ./...
# Run parser benchmarks with memory stats
go test -bench=. -benchmem ./internal/parser/...
# Run with specific count for stability
go test -bench=. -count=5 ./internal/parser/...
```
### Available Benchmarks
**Parser Benchmarks** (`internal/parser/parser_bench_test.go`):
- `BenchmarkParseGo` - Go file parsing
- `BenchmarkParseTypeScript` - TypeScript file parsing
- `BenchmarkParsePython` - Python file parsing
- `BenchmarkParseC` - C file parsing
- `BenchmarkParseCpp` - C++ file parsing
- `BenchmarkCacheHit` - Cache hit performance
- `BenchmarkCacheMiss` - Cache miss performance
- `BenchmarkContentHash` - xxHash performance
- `BenchmarkExtractSymbols` - Symbol extraction
### Expected Performance
Typical benchmark results (M1 Mac):
```
BenchmarkParseGo-8 5000 220000 ns/op 45000 B/op 850 allocs/op
BenchmarkParseTypeScript-8 3000 380000 ns/op 62000 B/op 1200 allocs/op
BenchmarkCacheHit-8 500000 2400 ns/op 128 B/op 3 allocs/op
BenchmarkContentHash-8 2000000 600 ns/op 0 B/op 0 allocs/op
```
Key observations:
- Cache hits are **~100x faster** than cache misses
- Content hashing is extremely fast (xxHash64)
- Parsing speed varies by language complexity
### Profiling
```bash
# CPU profiling
go test -bench=BenchmarkParseGo -cpuprofile=cpu.prof ./internal/parser/...
go tool pprof cpu.prof
# Memory profiling
go test -bench=BenchmarkParseGo -memprofile=mem.prof ./internal/parser/...
go tool pprof mem.prof
# Generate flame graph (requires pprof)
go tool pprof -http=:8080 cpu.prof
```
---
## Production Recommendations
### Environment Variables
```bash
# Essential configuration
export MCP_WORKSPACE_ROOT="/path/to/workspace"
export MCP_LSP_TIMEOUT="5m"
export MCP_SEARCH_TIMEOUT="30s"
export MCP_ENABLE_LSP="true"
# Optional optimizations
export MCP_FOLLOW_SYMLINKS="true"
export MCP_RESPECT_GITIGNORE="true"
```
### Logging Configuration
```bash
# Development
./mcp-filepuff -log-level debug -log-file /tmp/mcp-filepuff.log
# Production (minimal logging)
./mcp-filepuff -log-level warn
```
### Health Monitoring
Use the `ping` tool to verify server health:
```json
{"tool": "ping"}
```
Expected response: `"pong"`
### Performance Checklist
- [ ] Language servers installed and in PATH
- [ ] Project initialized (go.mod, package.json, etc.)
- [ ] Reasonable file size limits for your codebase
- [ ] LSP timeout appropriate for project size
- [ ] Adequate system memory (recommend 2+ GB free)
### Troubleshooting Slow Performance
1. **Slow Initial Operations**
- LSP servers need to index project
- Wait for initial indexing to complete
- Check LSP server logs for progress
2. **Slow Search**
- Check for overly broad patterns
- Exclude large directories (node_modules, vendor)
- Verify .gitignore is respected
3. **High Memory Usage**
- Disable unused LSP servers
- Check for memory leaks in language servers
- Monitor cache size
4. **Timeouts**
- Increase timeout values
- Check for I/O bottlenecks
- Verify network filesystems are responsive
---
## See Also
- [API.md](API.md) - Complete API reference
- [ERROR_CODES.md](ERROR_CODES.md) - Error code reference
- [README.md](../README.md) - Getting started
+5 -7
View File
@@ -4,21 +4,19 @@ go 1.25.5
require (
github.com/cespare/xxhash/v2 v2.3.0
github.com/goccy/go-json v0.10.5
github.com/goccy/go-json v0.10.6
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mark3labs/mcp-go v0.43.2
github.com/mark3labs/mcp-go v1.0.0
github.com/sergi/go-diff v1.4.0
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/text v0.41.0 // indirect
)
+22 -21
View File
@@ -1,24 +1,22 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -26,14 +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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mark3labs/mcp-go v1.0.0 h1:CZqCzXwUiTOstkIdW1MyOZGuM+LaKMEJSu+ZYluN4DU=
github.com/mark3labs/mcp-go v1.0.0/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s=
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/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/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
@@ -42,15 +40,18 @@ 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/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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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-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.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+96 -28
View File
@@ -2,6 +2,7 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -12,40 +13,46 @@ import (
// Config holds all configuration options for the MCP server.
type Config struct {
Formatters map[string]string `json:"formatters"`
WorkspaceRoot string `json:"workspace_root"`
LSPTimeout time.Duration `json:"lsp_timeout"`
SearchTimeout time.Duration `json:"search_timeout"`
MaxFileSize int64 `json:"max_file_size"`
MaxSearchResults int `json:"max_search_results"`
MaxEditSize int64 `json:"max_edit_size"`
EnableLSP bool `json:"enable_lsp"`
FollowSymlinks bool `json:"follow_symlinks"`
RespectGitignore bool `json:"respect_gitignore"`
Formatters map[string]string `json:"formatters"`
WorkspaceRoot string `json:"workspace_root"`
LSPTimeout time.Duration `json:"lsp_timeout"`
SearchTimeout time.Duration `json:"search_timeout"`
MaxFileSize int64 `json:"max_file_size"`
MaxParseSize int64 `json:"max_parse_size"`
MaxSearchResults int `json:"max_search_results"`
MaxEditSize int64 `json:"max_edit_size"`
EnableLSP bool `json:"enable_lsp"`
FollowSymlinks bool `json:"follow_symlinks"`
RespectGitignore bool `json:"respect_gitignore"`
ResourceLinkThresholdBytes int `json:"resource_link_threshold_bytes"`
}
// Default values for configuration.
const (
DefaultLSPTimeout = 5 * time.Minute
DefaultSearchTimeout = 30 * time.Second
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxSearchResults = 1000
DefaultMaxEditSize = 100 * 1024 // 100 KB
DefaultLSPTimeout = 5 * time.Minute
DefaultSearchTimeout = 30 * time.Second
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxSearchResults = 1000
DefaultMaxEditSize = 100 * 1024 // 100 KB
DefaultResourceLinkThresholdBytes = 64 * 1024 // 64 KiB
)
// Default returns a Config with default values.
func Default() *Config {
return &Config{
WorkspaceRoot: ".",
LSPTimeout: DefaultLSPTimeout,
SearchTimeout: DefaultSearchTimeout,
MaxFileSize: DefaultMaxFileSize,
MaxSearchResults: DefaultMaxSearchResults,
MaxEditSize: DefaultMaxEditSize,
EnableLSP: true,
Formatters: make(map[string]string),
FollowSymlinks: true,
RespectGitignore: true,
WorkspaceRoot: ".",
LSPTimeout: DefaultLSPTimeout,
SearchTimeout: DefaultSearchTimeout,
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
MaxSearchResults: DefaultMaxSearchResults,
MaxEditSize: DefaultMaxEditSize,
EnableLSP: true,
Formatters: make(map[string]string),
FollowSymlinks: true,
RespectGitignore: true,
ResourceLinkThresholdBytes: DefaultResourceLinkThresholdBytes,
}
}
@@ -65,13 +72,36 @@ func Load(workspaceRoot string) (*Config, error) {
cfg.WorkspaceRoot = cwd
}
// Try to load from config file in workspace root
// Try to load from config file in workspace root.
// Save WorkspaceRoot before loading config file so it cannot be overridden.
savedRoot := cfg.WorkspaceRoot
configPath := filepath.Join(cfg.WorkspaceRoot, ".mcp-filepuff.json")
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
}
// Restore WorkspaceRoot — config file must not override path guards.
cfg.WorkspaceRoot = savedRoot
// Clamp size/count limits so the config file cannot request excessive memory.
const maxAllowedSize int64 = 100 * 1024 * 1024 // 100 MB
const maxAllowedSearchResults = 100000
if cfg.MaxFileSize > maxAllowedSize {
cfg.MaxFileSize = maxAllowedSize
}
if 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
cfg.loadFromEnv()
@@ -169,6 +199,44 @@ func (c *Config) IsPathAllowed(path string) bool {
// Check if the path is within workspace (doesn't start with ..)
// This prevents both "../" attacks and symlink bypasses
// Also reject empty relative path (which means it's the workspace root itself)
return rel != "." && !strings.HasPrefix(rel, "..")
// The workspace root itself (rel == ".") is a valid, allowed path
return !strings.HasPrefix(rel, "..")
}
// Validate validates the configuration and returns an error if invalid.
// Checks include:
// - MaxFileSize and MaxParseSize must be positive
// - LSPTimeout must be positive
// - WorkspaceRoot must exist (when not empty)
func (c *Config) Validate() error {
// Validate MaxFileSize
if c.MaxFileSize <= 0 {
return fmt.Errorf("max_file_size must be positive, got %d", c.MaxFileSize)
}
// Validate MaxParseSize
if c.MaxParseSize <= 0 {
return fmt.Errorf("max_parse_size must be positive, got %d", c.MaxParseSize)
}
// Validate LSPTimeout
if c.LSPTimeout <= 0 {
return fmt.Errorf("lsp_timeout must be positive, got %v", c.LSPTimeout)
}
// Validate WorkspaceRoot exists
if c.WorkspaceRoot != "" {
info, err := os.Stat(c.WorkspaceRoot)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("workspace_root does not exist: %s", c.WorkspaceRoot)
}
return fmt.Errorf("cannot access workspace_root: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("workspace_root is not a directory: %s", c.WorkspaceRoot)
}
}
return nil
}
+464 -4
View File
@@ -45,7 +45,7 @@ func TestLoad(t *testing.T) {
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg, err := Load(tmpDir)
if err != nil {
@@ -108,7 +108,7 @@ func TestIsPathAllowed(t *testing.T) {
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg := Default()
cfg.WorkspaceRoot = tmpDir
@@ -156,7 +156,7 @@ func TestLoadWithConfigFile(t *testing.T) {
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Write config file
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
@@ -164,7 +164,7 @@ func TestLoadWithConfigFile(t *testing.T) {
"enable_lsp": false,
"follow_symlinks": false
}`
err = os.WriteFile(configPath, []byte(configContent), 0600)
err = os.WriteFile(configPath, []byte(configContent), 0o600)
if err != nil {
t.Fatalf("failed to write config file: %v", err)
}
@@ -182,3 +182,463 @@ func TestLoadWithConfigFile(t *testing.T) {
t.Error("expected FollowSymlinks to be false from config file")
}
}
// 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.
func TestValidate(t *testing.T) {
tests := []struct {
name string
cfg *Config
expectErr bool
errMsg string
}{
{
name: "valid_config",
cfg: Default(),
expectErr: false,
},
{
name: "invalid_max_file_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: -1,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_file_size must be positive",
},
{
name: "zero_max_file_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: 0,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_file_size must be positive",
},
{
name: "invalid_max_parse_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: -1,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_parse_size must be positive",
},
{
name: "zero_max_parse_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: 0,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_parse_size must be positive",
},
{
name: "invalid_lsp_timeout",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: -1 * time.Second,
},
expectErr: true,
errMsg: "lsp_timeout must be positive",
},
{
name: "zero_lsp_timeout",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: 0,
},
expectErr: true,
errMsg: "lsp_timeout must be positive",
},
{
name: "nonexistent_workspace",
cfg: &Config{
WorkspaceRoot: "/nonexistent/path/that/does/not/exist",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "workspace_root does not exist",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cfg.Validate()
if tt.expectErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.errMsg)
} else if !contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
// TestValidateWithFile tests validation with an actual file as workspace root.
func TestValidateWithFile(t *testing.T) {
// Create a temporary file
tmpFile, err := os.CreateTemp("", "test-file-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
_ = tmpFile.Close()
t.Cleanup(func() { _ = os.Remove(tmpFile.Name()) })
cfg := &Config{
WorkspaceRoot: tmpFile.Name(),
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
}
err = cfg.Validate()
if err == nil {
t.Error("expected error when workspace_root is a file, got nil")
} else if !contains(err.Error(), "is not a directory") {
t.Errorf("expected error about not being a directory, got: %v", err)
}
}
// TestLoadEnvironmentPrecedence tests environment variable precedence.
func TestLoadEnvironmentPrecedence(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Write a config file with specific values
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
configContent := `{
"enable_lsp": false,
"follow_symlinks": false,
"lsp_timeout": 60000000000
}`
if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Save and restore environment variables
origEnableLSP := os.Getenv("MCP_ENABLE_LSP")
origLSPTimeout := os.Getenv("MCP_LSP_TIMEOUT")
t.Cleanup(func() {
_ = os.Setenv("MCP_ENABLE_LSP", origEnableLSP)
_ = os.Setenv("MCP_LSP_TIMEOUT", origLSPTimeout)
})
// Set environment variables that should override config file
_ = os.Setenv("MCP_ENABLE_LSP", "false")
_ = os.Setenv("MCP_LSP_TIMEOUT", "2m")
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Environment variable should override config file
if cfg.LSPTimeout != 2*time.Minute {
t.Errorf("expected LSP timeout 2m from env, got %v", cfg.LSPTimeout)
}
if cfg.EnableLSP {
t.Error("expected EnableLSP to be false from env")
}
// Value from config file (not overridden by env)
if cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be false from config file")
}
}
// TestIsPathAllowedEdgeCases tests edge cases in path validation.
func TestIsPathAllowedEdgeCases(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg := Default()
cfg.WorkspaceRoot = tmpDir
tests := []struct {
name string
path string
allowed bool
desc string
}{
{
name: "workspace_root_itself",
path: tmpDir,
allowed: true,
desc: "workspace root itself should be allowed",
},
{
name: "dot_relative",
path: ".",
allowed: false,
desc: "current directory should not be allowed",
},
{
name: "empty_path",
path: "",
allowed: false,
desc: "empty path should not be allowed",
},
{
name: "path_with_double_dots",
path: filepath.Join(tmpDir, "..", filepath.Base(tmpDir), "file.txt"),
allowed: true,
desc: "path with .. that resolves back inside workspace should be allowed",
},
{
name: "deeply_nested_valid",
path: filepath.Join(tmpDir, "a", "b", "c", "file.txt"),
allowed: true,
desc: "deeply nested path should be allowed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cfg.IsPathAllowed(tt.path)
if result != tt.allowed {
t.Errorf("%s: IsPathAllowed(%q) = %v, want %v", tt.desc, tt.path, result, tt.allowed)
}
})
}
}
// TestIsPathAllowedWithSymlinks tests path validation with symbolic links.
func TestIsPathAllowedWithSymlinks(t *testing.T) {
// Create temporary directories
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
realDir := filepath.Join(tmpDir, "real")
if err := os.MkdirAll(realDir, 0o755); err != nil {
t.Fatalf("failed to create real dir: %v", err)
}
// Create a symlink inside workspace
symlinkPath := filepath.Join(tmpDir, "link")
if err := os.Symlink(realDir, symlinkPath); err != nil {
t.Skip("symlink creation not supported on this system")
}
cfg := Default()
cfg.WorkspaceRoot = tmpDir
// File accessed through symlink should be allowed
fileViaSymlink := filepath.Join(symlinkPath, "test.txt")
if !cfg.IsPathAllowed(fileViaSymlink) {
t.Error("file accessed through symlink inside workspace should be allowed")
}
// Direct access should also work
fileDirect := filepath.Join(realDir, "test.txt")
if !cfg.IsPathAllowed(fileDirect) {
t.Error("file accessed directly should be allowed")
}
}
// TestLoadDefaultValues tests that default values are properly set.
func TestLoadDefaultValues(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Clear any environment variables that might affect defaults
origVars := []struct{ key, val string }{
{"MCP_ENABLE_LSP", os.Getenv("MCP_ENABLE_LSP")},
{"MCP_FOLLOW_SYMLINKS", os.Getenv("MCP_FOLLOW_SYMLINKS")},
{"MCP_RESPECT_GITIGNORE", os.Getenv("MCP_RESPECT_GITIGNORE")},
}
t.Cleanup(func() {
for _, v := range origVars {
_ = os.Setenv(v.key, v.val)
}
})
for _, v := range origVars {
_ = os.Unsetenv(v.key)
}
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Verify all default values
if cfg.LSPTimeout != DefaultLSPTimeout {
t.Errorf("expected LSPTimeout %v, got %v", DefaultLSPTimeout, cfg.LSPTimeout)
}
if cfg.SearchTimeout != DefaultSearchTimeout {
t.Errorf("expected SearchTimeout %v, got %v", DefaultSearchTimeout, cfg.SearchTimeout)
}
if cfg.MaxFileSize != DefaultMaxFileSize {
t.Errorf("expected MaxFileSize %d, got %d", DefaultMaxFileSize, cfg.MaxFileSize)
}
if cfg.MaxParseSize != DefaultMaxParseSize {
t.Errorf("expected MaxParseSize %d, got %d", DefaultMaxParseSize, cfg.MaxParseSize)
}
if cfg.MaxSearchResults != DefaultMaxSearchResults {
t.Errorf("expected MaxSearchResults %d, got %d", DefaultMaxSearchResults, cfg.MaxSearchResults)
}
if cfg.MaxEditSize != DefaultMaxEditSize {
t.Errorf("expected MaxEditSize %d, got %d", DefaultMaxEditSize, cfg.MaxEditSize)
}
if !cfg.EnableLSP {
t.Error("expected EnableLSP to be true by default")
}
if !cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be true by default")
}
if !cfg.RespectGitignore {
t.Error("expected RespectGitignore to be true by default")
}
if cfg.Formatters == nil {
t.Error("expected Formatters map to be initialized")
}
}
// TestConfigFileLoadingErrors tests error handling during config file loading.
func TestConfigFileLoadingErrors(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Write invalid JSON
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
invalidJSON := `{"enable_lsp": invalid_value}`
if err := os.WriteFile(configPath, []byte(invalidJSON), 0o600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
_, err = Load(tmpDir)
if err == nil {
t.Error("expected error when loading invalid JSON config file")
}
}
// TestIsPathAllowed_SymlinkOutsideWorkspace verifies that symlinks pointing
// outside the workspace are rejected (T-01).
func TestIsPathAllowed_SymlinkOutsideWorkspace(t *testing.T) {
// Create two separate temp dirs: one as workspace, one as outside target
workspace, err := os.MkdirTemp("", "mcp-workspace-*")
if err != nil {
t.Fatalf("failed to create workspace dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(workspace) })
outside, err := os.MkdirTemp("", "mcp-outside-*")
if err != nil {
t.Fatalf("failed to create outside dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(outside) })
// Create a file outside the workspace
outsideFile := filepath.Join(outside, "secret.txt")
if err := os.WriteFile(outsideFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("failed to write outside file: %v", err)
}
// Create a symlink inside the workspace pointing outside
symlinkPath := filepath.Join(workspace, "escape-link")
if err := os.Symlink(outsideFile, symlinkPath); err != nil {
t.Skip("symlink creation not supported on this system")
}
cfg := Default()
cfg.WorkspaceRoot = workspace
// The symlink resolves to a file outside workspace — must be rejected
if cfg.IsPathAllowed(symlinkPath) {
t.Error("symlink pointing outside workspace should NOT be allowed")
}
// Direct access to the outside file should also be rejected
if cfg.IsPathAllowed(outsideFile) {
t.Error("file outside workspace should NOT be allowed")
}
// File inside workspace should still be allowed
insideFile := filepath.Join(workspace, "safe.txt")
if !cfg.IsPathAllowed(insideFile) {
t.Error("file inside workspace should be allowed")
}
// Workspace root itself should be allowed (C-08 fix)
if !cfg.IsPathAllowed(workspace) {
t.Error("workspace root itself should be allowed")
}
}
// Helper function to check if a string contains a substring.
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && containsHelper(s, substr)))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+1 -1
View File
@@ -126,7 +126,7 @@ func TestIsPathAllowed_BasicCases(t *testing.T) {
{
name: "workspace root itself",
path: tmpDir,
expected: false, // Empty relative path
expected: true, // Workspace root is a valid, allowed path (needed for ast_query)
},
}
+67
View File
@@ -0,0 +1,67 @@
// Package cursor implements opaque pagination cursors for MCP tools.
// A cursor encodes an offset into a result stream plus a query hash so stale
// cursors from different queries fail cleanly.
//
// Encoding: base64url(json({"offset":N,"query_hash":"hex"}))
// The query_hash is a hex-encoded sha256 over the deterministic query params.
package cursor
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"sort"
"strings"
json "github.com/goccy/go-json"
)
// payload is the JSON structure inside a cursor.
type payload struct {
Offset int `json:"offset"`
QueryHash string `json:"query_hash"`
}
// Encode creates an opaque cursor string from an offset and query hash.
func Encode(offset int, queryHash string) string {
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)
return base64.RawURLEncoding.EncodeToString(b)
}
// Decode parses a cursor string. Returns offset, queryHash, error.
func Decode(cursor string) (int, string, error) {
b, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return 0, "", fmt.Errorf("invalid cursor encoding: %w", err)
}
var p payload
if err := json.Unmarshal(b, &p); err != nil {
return 0, "", fmt.Errorf("invalid cursor payload: %w", err)
}
return p.Offset, p.QueryHash, nil
}
// HashParams computes a deterministic query hash from a set of key=value params.
// Keys are sorted before hashing so order doesn't matter.
func HashParams(params map[string]string) string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for _, k := range keys {
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(params[k])
sb.WriteByte('\n')
}
sum := sha256.Sum256([]byte(sb.String()))
return hex.EncodeToString(sum[:])
}
+57
View File
@@ -0,0 +1,57 @@
package cursor
import (
"testing"
)
func TestEncodeDecodRoundTrip(t *testing.T) {
hash := HashParams(map[string]string{"a": "1", "b": "2"})
encoded := Encode(42, hash)
if encoded == "" {
t.Fatal("Encode returned empty string")
}
offset, gotHash, err := Decode(encoded)
if err != nil {
t.Fatalf("Decode error: %v", err)
}
if offset != 42 {
t.Errorf("offset: got %d, want 42", offset)
}
if gotHash != hash {
t.Errorf("hash mismatch: got %s, want %s", gotHash, hash)
}
}
func TestDecodeInvalid(t *testing.T) {
_, _, err := Decode("!!!notbase64!!!")
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
}
func TestDecodeCorruptPayload(t *testing.T) {
import64 := "bm90anNvbg" // "notjson" in base64
_, _, err := Decode(import64)
if err == nil {
t.Error("expected error for corrupt payload, got nil")
}
}
func TestHashParamsDeterministic(t *testing.T) {
// Same params regardless of insertion order
h1 := HashParams(map[string]string{"z": "last", "a": "first"})
h2 := HashParams(map[string]string{"a": "first", "z": "last"})
if h1 != h2 {
t.Errorf("hash not deterministic: %s != %s", h1, h2)
}
}
func TestHashParamsDifferentForDifferentQueries(t *testing.T) {
h1 := HashParams(map[string]string{"pattern": "foo"})
h2 := HashParams(map[string]string{"pattern": "bar"})
if h1 == h2 {
t.Error("different queries should produce different hashes")
}
}
+355 -229
View File
@@ -6,37 +6,18 @@ import (
"context"
"fmt"
"os"
"regexp"
"slices"
"strings"
"sync"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
"github.com/sergi/go-diff/diffmatchpatch"
sitter "github.com/smacker/go-tree-sitter"
)
// Global regex cache for compiled patterns (thread-safe)
var regexCache sync.Map // string -> *regexp.Regexp
// compileRegex compiles a regex pattern with caching for performance.
func compileRegex(pattern string) (*regexp.Regexp, error) {
// Check cache first
if cached, ok := regexCache.Load(pattern); ok {
return cached.(*regexp.Regexp), nil
}
// Compile and cache
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
regexCache.Store(pattern, re)
return re, nil
}
// EditOperation defines the type of edit operation.
type EditOperation string
@@ -80,17 +61,48 @@ type EditResult struct {
// Engine performs AST-aware edits.
type Engine struct {
registry *parser.Registry
dmp *diffmatchpatch.DiffMatchPatch
fileLocks sync.Map // map[string]*sync.Mutex for per-file locking
// pathAllowed re-checks that a target path is admissible (within the
// workspace root) immediately before a write. Injected to avoid coupling
// edit to config; nil disables the check (used in unit tests).
pathAllowed func(string) bool
}
// NewEngine creates a new edit engine.
// NewEngine creates a new edit engine with no write-time path validation.
func NewEngine(registry *parser.Registry) *Engine {
return NewEngineWithValidator(registry, nil)
}
// NewEngineWithValidator creates an edit engine that re-validates each target
// path with pathAllowed immediately before writing it, closing most of the
// time-of-check/time-of-use window between the handler's admission check and the
// actual write (a symlink swapped in afterward is rejected).
func NewEngineWithValidator(registry *parser.Registry, pathAllowed func(string) bool) *Engine {
return &Engine{
registry: registry,
fileLocks: sync.Map{},
registry: registry,
dmp: diffmatchpatch.New(),
fileLocks: sync.Map{},
pathAllowed: pathAllowed,
}
}
// writeValidatedFile writes content to path, preserving the file's existing
// permission bits, after re-checking that path is still admissible. The re-check
// resolves symlinks at write time, so a symlink swapped in after the caller's
// original validation cannot redirect the write outside the workspace root.
func (e *Engine) writeValidatedFile(path string, content []byte) error {
if e.pathAllowed != nil && !e.pathAllowed(path) {
return errors.New(errors.ErrPathNotAllowed, "path is outside the workspace root").
WithContext("path", path)
}
perm := os.FileMode(0o600) // default fallback when the file can't be stat'd
if info, err := os.Stat(path); err == nil {
perm = info.Mode().Perm()
}
return os.WriteFile(path, content, perm)
}
// lockFile acquires a lock for the specified file and returns an unlock function.
// This prevents concurrent edits to the same file which could cause corruption.
func (e *Engine) lockFile(filePath string) func() {
@@ -186,19 +198,17 @@ func (e *Engine) performASTEdit(ctx context.Context, edit *ASTEdit, apply bool)
}
// Generate diff
diff := generateDiff(string(content), string(newContent), edit.File)
diff := e.generateDiff(string(content), string(newContent), edit.File)
result := &EditResult{
Success: true,
Diff: diff,
OriginalContent: string(content),
NewContent: string(newContent),
Applied: false,
Success: true,
Diff: diff,
Applied: false,
}
// Apply changes if requested
if apply {
if err := os.WriteFile(edit.File, newContent, 0600); err != nil {
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
structuredErr := errors.NewFileNotWritableError(edit.File, err)
return &EditResult{
Success: false,
@@ -238,19 +248,17 @@ func (e *Engine) performTextEdit(_ context.Context, edit *ASTEdit, apply bool) (
}
// Generate diff
diff := generateDiff(string(content), string(newContent), edit.File)
diff := e.generateDiff(string(content), string(newContent), edit.File)
result := &EditResult{
Success: true,
Diff: diff,
OriginalContent: string(content),
NewContent: string(newContent),
Applied: false,
Success: true,
Diff: diff,
Applied: false,
}
// Apply changes if requested
if apply {
if err := os.WriteFile(edit.File, newContent, 0600); err != nil {
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
structuredErr := errors.NewFileNotWritableError(edit.File, err)
return &EditResult{
Success: false,
@@ -277,7 +285,11 @@ func (e *Engine) validateBaseEdit(edit *ASTEdit) error {
switch edit.Operation {
case EditReplace, EditInsertBefore, EditInsertAfter:
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:
// new_content not required
@@ -319,7 +331,7 @@ func (e *Engine) validateTextEdit(edit *ASTEdit) error {
// Validate regex pattern if provided (uses cached compilation)
if edit.Selector.TextPattern != "" {
if _, err := compileRegex(edit.Selector.TextPattern); err != nil {
if _, err := util.CompileRegex(edit.Selector.TextPattern); err != nil {
return errors.Wrap(errors.ErrInvalidEdit, "invalid text_pattern regex", err)
}
}
@@ -352,6 +364,12 @@ func (e *Engine) resolveSelector(sel ASTSelector, tree *sitter.Tree, content []b
return nil, errors.NewNodeNotFoundError(selectorDesc)
}
// When using AtLine without a specific Kind, prefer the smallest (most specific) node.
// This prevents matching large parent nodes like source_file when we want a specific declaration.
if sel.AtLine > 0 && sel.Kind == "" {
matches = sortBySpecificity(matches)
}
// Use index to select specific match
index := sel.Index
if index < 0 || index >= len(matches) {
@@ -361,6 +379,77 @@ func (e *Engine) resolveSelector(sel ASTSelector, tree *sitter.Tree, content []b
return matches[index], nil
}
// sortBySpecificity sorts nodes so that the most useful nodes come first.
// Prefers: 1) Named nodes (declarations/statements) over anonymous tokens
// 2) Smaller nodes over larger ones (more specific)
func sortBySpecificity(nodes []*sitter.Node) []*sitter.Node {
if len(nodes) <= 1 {
return nodes
}
result := make([]*sitter.Node, len(nodes))
copy(result, nodes)
slices.SortFunc(result, func(a, b *sitter.Node) int {
if shouldPrefer(a, b) {
return -1
}
if shouldPrefer(b, a) {
return 1
}
return 0
})
return result
}
// shouldPrefer returns true if node a should come before node b.
func shouldPrefer(a, b *sitter.Node) bool {
// Prefer named nodes over anonymous tokens
aIsNamed := a.IsNamed()
bIsNamed := b.IsNamed()
if aIsNamed && !bIsNamed {
return true
}
if !aIsNamed && bIsNamed {
return false
}
// Both named or both anonymous: prefer smaller meaningful nodes
// But filter out very small nodes (likely just identifiers/literals)
aSize := a.EndByte() - a.StartByte()
bSize := b.EndByte() - b.StartByte()
// If both are named, prefer "declaration" or "statement" types
aIsDecl := isDeclarationLike(a.Type())
bIsDecl := isDeclarationLike(b.Type())
if aIsDecl && !bIsDecl {
return true
}
if !aIsDecl && bIsDecl {
return false
}
// Same category: prefer smaller
return aSize < bSize
}
// isDeclarationLike returns true for node types that represent declarations or statements.
func isDeclarationLike(nodeType string) bool {
// Common declaration/statement patterns across languages
declarationPatterns := []string{
"declaration", "definition", "statement", "spec", "clause",
"function", "method", "class", "struct", "interface", "type",
"import", "package", "module", "const", "var", "let",
}
for _, pattern := range declarationPatterns {
if strings.Contains(nodeType, pattern) {
return true
}
}
return false
}
// matchesSelector checks if a node matches the selector criteria.
func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte) bool {
// Check kind
@@ -411,121 +500,255 @@ func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte
return true
}
// applyEdit applies the edit operation to the content.
// applyEdit applies an AST-mode edit. new_content is inserted verbatim — the caller
// 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) {
startByte := node.StartByte()
endByte := node.EndByte()
eol := detectLineEnding(content)
newContent := normalizeLineEndings(edit.NewContent, eol)
return spliceContent(edit.Operation, content, int(node.StartByte()), int(node.EndByte()), newContent, eol)
}
// Detect and preserve indentation
indentation := detectIndentation(content, startByte)
newContent := indentContent(edit.NewContent, indentation)
// 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
switch edit.Operation {
switch op {
case EditReplace:
result = append(result, content[:startByte]...)
result = append(result, []byte(newContent)...)
result = append(result, content[endByte:]...)
result = append(result, content[:start]...)
result = append(result, newContent...)
// Restore a line terminator if the replaced range ended with one but the
// replacement does not, to prevent merging with the following line.
if end > start && content[end-1] == '\n' && !endsWithNewline(newContent) {
result = append(result, eol...)
}
result = append(result, content[end:]...)
case EditInsertBefore:
result = append(result, content[:startByte]...)
result = append(result, []byte(newContent)...)
result = append(result, '\n')
result = append(result, content[startByte:]...)
insertion := newContent
if !endsWithNewline(insertion) {
insertion += eol
}
result = append(result, content[:start]...)
result = append(result, insertion...)
result = append(result, content[start:]...)
case EditInsertAfter:
result = append(result, content[:endByte]...)
result = append(result, '\n')
result = append(result, []byte(newContent)...)
result = append(result, content[endByte:]...)
insertion := newContent
// Separate from preceding content.
if end > 0 && content[end-1] != '\n' && !startsWithNewline(insertion) {
insertion = eol + insertion
}
// Separate from following content.
if !endsWithNewline(insertion) && end < len(content) && content[end] != '\n' {
insertion += eol
}
result = append(result, content[:end]...)
result = append(result, insertion...)
result = append(result, content[end:]...)
case EditDelete:
result = append(result, content[:startByte]...)
result = append(result, content[endByte:]...)
result = append(result, content[:start]...)
result = append(result, content[end:]...)
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
}
// detectIndentation detects the indentation at a given byte position.
func detectIndentation(content []byte, bytePos uint32) string {
// Find the start of the line
lineStart := int(bytePos)
for lineStart > 0 && content[lineStart-1] != '\n' {
lineStart--
}
// noNewlineMarker is the git-style annotation emitted after a diff line whose source
// version has no trailing newline.
const noNewlineMarker = "\\ No newline at end of file\n"
// Extract leading whitespace
var indent strings.Builder
for i := lineStart; i < int(bytePos) && i < len(content); i++ {
c := content[i]
if c == ' ' || c == '\t' {
indent.WriteByte(c)
} else {
break
}
}
return indent.String()
// diffLine represents a single line in the diff with its type and content.
type diffLine struct {
op diffmatchpatch.Operation
text string // line content without trailing newline
oldN int // 1-based line number in original (0 if insert)
newN int // 1-based line number in modified (0 if delete)
}
// 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")
}
// 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.
// Uses Myers diff algorithm for accurate and readable diffs.
func generateDiff(original, modified, filename string) string {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(original, modified, false)
// Uses a line-level Myers diff and outputs a unified diff with 3 lines of context
// before/after each change, merging close hunks.
func (e *Engine) generateDiff(original, modified, filename string) string {
dmp := e.dmp
// Cleanup for readability
// Line-level diffing: encode each line as a single rune, diff the encoded strings,
// then decode back to real lines.
chars1, chars2, lineArray := dmp.DiffLinesToChars(original, modified)
diffs := dmp.DiffCharsToLines(dmp.DiffMain(chars1, chars2, false), lineArray)
diffs = dmp.DiffCleanupSemantic(diffs)
// Convert to unified diff format
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("--- %s\n", filename))
buf.WriteString(fmt.Sprintf("+++ %s\n", filename))
// Track whether each version lacks a final newline, so the diff is annotated
// git-style ("\ No newline at end of file") instead of implying a phantom one.
origNoEOL := len(original) > 0 && !strings.HasSuffix(original, "\n")
modNoEOL := len(modified) > 0 && !strings.HasSuffix(modified, "\n")
// Group diffs into hunks
lineNum := 1
for _, diff := range diffs {
lines := strings.Split(diff.Text, "\n")
for i, line := range lines {
// Skip empty last line from split
if i == len(lines)-1 && line == "" {
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 _, raw := range strings.SplitAfter(d.Text, "\n") {
if raw == "" {
continue
}
switch diff.Type {
case diffmatchpatch.DiffDelete:
buf.WriteString(fmt.Sprintf("-%s\n", line))
case diffmatchpatch.DiffInsert:
buf.WriteString(fmt.Sprintf("+%s\n", line))
// 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 {
case diffmatchpatch.DiffEqual:
buf.WriteString(fmt.Sprintf(" %s\n", line))
lineNum++
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
oldLine++
newLine++
case diffmatchpatch.DiffDelete:
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
oldLine++
case diffmatchpatch.DiffInsert:
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
newLine++
}
}
}
return lines, oldLine - 1, newLine - 1
}
return buf.String()
// 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
var ranges []indexRange
for i, l := range lines {
if l.op == diffmatchpatch.DiffEqual {
continue
}
rStart := max(i-contextSize, 0)
rEnd := min(i+contextSize, len(lines)-1)
if len(ranges) > 0 && rStart <= ranges[len(ranges)-1].end+1 {
ranges[len(ranges)-1].end = rEnd // merge with previous
} else {
ranges = append(ranges, indexRange{rStart, rEnd})
}
}
return ranges
}
// hunkBounds computes the unified-diff hunk header line numbers and counts for
// lines[start:end+1]. Each line carries both running line numbers (see
// flattenDiffLines), so the hunk start is simply the first line's numbers. When a
// 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,
// clamped to 0 at the start of the file (e.g. "@@ -0,0 +1,N @@").
func hunkBounds(lines []diffLine, start, end int) (oldStart, oldCount, newStart, newCount int) {
oldStart = lines[start].oldN
newStart = lines[start].newN
for i := start; i <= end; i++ {
switch lines[i].op {
case diffmatchpatch.DiffEqual:
oldCount++
newCount++
case diffmatchpatch.DiffDelete:
oldCount++
case diffmatchpatch.DiffInsert:
newCount++
}
}
if oldCount == 0 {
oldStart = max(0, oldStart-1)
}
if newCount == 0 {
newStart = max(0, newStart-1)
}
return
}
// 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.
func writeDiffBody(buf *bytes.Buffer, lines []diffLine, start, end int, origNoEOL, modNoEOL bool, maxOldN, maxNewN int) {
for i := start; i <= end; i++ {
l := lines[i]
switch l.op {
case diffmatchpatch.DiffEqual:
fmt.Fprintf(buf, " %s\n", l.text)
if (origNoEOL && l.oldN == maxOldN) || (modNoEOL && l.newN == maxNewN) {
buf.WriteString(noNewlineMarker)
}
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)
}
}
}
}
// resolveTextSelector finds the byte range for a text-based selection.
@@ -569,24 +792,6 @@ func (e *Engine) findExactText(content []byte, text string, index int) (start, e
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("text not found: %q", truncateString(text, 50)))
}
// If multiple matches and no index specified, require explicit selection
if len(matches) > 1 && index == 0 {
// Check if index was explicitly set to 0 or just defaulted
// Since we can't distinguish, we'll allow index 0 but warn about multiple matches
// Actually, let's be strict and require explicit index for multiple matches
locations := make([]string, 0, min(len(matches), 5))
for i, m := range matches {
if i >= 5 {
locations = append(locations, fmt.Sprintf("... and %d more", len(matches)-5))
break
}
line := countLines(content[:m.start]) + 1
locations = append(locations, fmt.Sprintf("line %d", line))
}
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("text matches %d locations (%s); use selector_index to specify which one (0-%d)",
len(matches), strings.Join(locations, ", "), len(matches)-1))
}
if index >= len(matches) {
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("selector_index %d out of range (found %d matches)", index, len(matches)))
}
@@ -596,7 +801,7 @@ func (e *Engine) findExactText(content []byte, text string, index int) (start, e
// findRegexPattern finds a regex pattern match in content.
func (e *Engine) findRegexPattern(content []byte, pattern string, index int) (start, end int, err error) {
re, err := compileRegex(pattern)
re, err := util.CompileRegex(pattern)
if err != nil {
return 0, 0, errors.Wrap(errors.ErrInvalidEdit, "invalid regex pattern", err)
}
@@ -606,21 +811,6 @@ func (e *Engine) findRegexPattern(content []byte, pattern string, index int) (st
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("pattern not found: %q", truncateString(pattern, 50)))
}
// If multiple matches and index is 0 (default), show error with locations
if len(matches) > 1 && index == 0 {
locations := make([]string, 0, min(len(matches), 5))
for i, m := range matches {
if i >= 5 {
locations = append(locations, fmt.Sprintf("... and %d more", len(matches)-5))
break
}
line := countLines(content[:m[0]]) + 1
locations = append(locations, fmt.Sprintf("line %d", line))
}
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("pattern matches %d locations (%s); use selector_index to specify which one (0-%d)",
len(matches), strings.Join(locations, ", "), len(matches)-1))
}
if index >= len(matches) {
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("selector_index %d out of range (found %d matches)", index, len(matches)))
}
@@ -643,6 +833,10 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
}
lines := bytes.Split(content, []byte("\n"))
// Trim phantom empty element from trailing newline
if len(lines) > 0 && len(lines[len(lines)-1]) == 0 {
lines = lines[:len(lines)-1]
}
totalLines := len(lines)
// Convert to 0-indexed
@@ -658,7 +852,7 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
// Calculate byte positions
start = 0
for i := 0; i < startIdx; i++ {
for i := range startIdx {
start += len(lines[i]) + 1 // +1 for newline
}
@@ -673,63 +867,11 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
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) {
// Detect indentation at the selection point
indentation := detectIndentationAtByte(content, start)
indentedContent := indentContent(newContent, indentation)
var result []byte
switch op {
case EditReplace:
result = append(result, content[:start]...)
result = append(result, []byte(indentedContent)...)
result = append(result, content[end:]...)
case EditInsertBefore:
result = append(result, content[:start]...)
result = append(result, []byte(indentedContent)...)
result = append(result, '\n')
result = append(result, content[start:]...)
case EditInsertAfter:
result = append(result, content[:end]...)
result = append(result, '\n')
result = append(result, []byte(indentedContent)...)
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
}
// detectIndentationAtByte detects indentation at a byte position.
func detectIndentationAtByte(content []byte, bytePos int) string {
// Find the start of the line
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()
eol := detectLineEnding(content)
return spliceContent(op, content, start, end, normalizeLineEndings(newContent, eol), eol)
}
// truncateString truncates a string to maxLen with ellipsis.
@@ -737,21 +879,5 @@ func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
// countLines counts the number of newlines in content.
func countLines(content []byte) int {
return bytes.Count(content, []byte("\n"))
}
// ValidateLanguage checks if AST editing is supported for a file.
// Returns nil for supported languages, error for unsupported.
// Note: Text-based editing is always available regardless of this check.
func ValidateLanguage(filename string) error {
lang := protocol.DetectLanguage(filename)
if lang == protocol.LangUnknown {
return fmt.Errorf("unsupported file type for AST editing: %s (text-based editing is available)", filename)
}
return nil
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
}
+1100 -39
View File
File diff suppressed because it is too large Load Diff
+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)
}
}
+111 -3
View File
@@ -16,6 +16,16 @@ import (
json "github.com/goccy/go-json"
)
// ProcessKillTimeout is the timeout for waiting for a process to exit before force killing.
const ProcessKillTimeout = 5 * time.Second
// StderrBufferSize is the buffer size for draining stderr.
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.
type Client struct {
stdin io.WriteCloser
@@ -29,6 +39,8 @@ type Client struct {
runningMu sync.RWMutex
stopOnce sync.Once
mu sync.Mutex
stderrMu sync.Mutex
stderrTail []byte // most recent stderr, capped at StderrCaptureMax (diagnostics)
running bool
}
@@ -104,12 +116,60 @@ func NewClient(cmd *exec.Cmd) (*Client, error) {
notifications: make(chan *Notification, 100),
}
// Start reader goroutine
// Start reader goroutine for stdout
go c.readLoop()
// Start stderr drain goroutine to prevent pipe buffer from filling up
go c.drainStderr()
return c, nil
}
// drainStderr consumes stderr output to prevent the LSP server from blocking.
// 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() {
buf := make([]byte, StderrBufferSize)
for {
select {
case <-c.done:
return
default:
}
n, err := c.stderr.Read(buf)
if n > 0 {
c.captureStderr(buf[:n])
}
if err != nil {
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.
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
c.runningMu.RLock()
@@ -208,7 +268,7 @@ func (c *Client) Close() error {
select {
case <-done:
// Clean exit
case <-time.After(5 * time.Second):
case <-time.After(ProcessKillTimeout):
// Force kill
_ = c.cmd.Process.Kill()
}
@@ -241,7 +301,16 @@ func (c *Client) send(msg interface{}) error {
}
// readLoop reads and dispatches messages from the server.
// On exit (for any reason), it drains all pending Call waiters with a
// synthetic error so that goroutines blocked in Call are unblocked.
func (c *Client) readLoop() {
// On exit (EOF/error from a crashed or closed server) mark the client not
// running so subsequent Calls fail fast — otherwise they pass the running
// guard, send to a dead stdin, and block until their own timeout. Then
// unblock any already-in-flight waiters. LIFO: setRunning(false) runs first.
defer c.drainPending()
defer c.setRunning(false)
reader := bufio.NewReader(c.stdout)
for {
@@ -284,7 +353,14 @@ func (c *Client) readLoop() {
if err := json.Unmarshal(body, &resp); err == nil && resp.ID != 0 {
c.mu.Lock()
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()
continue
@@ -302,9 +378,41 @@ func (c *Client) readLoop() {
}
}
// drainPending sends a synthetic error response to every pending Call waiter
// so that goroutines blocked in Call are unblocked when readLoop exits.
func (c *Client) drainPending() {
c.mu.Lock()
defer c.mu.Unlock()
for id, ch := range c.pending {
// Non-blocking, matching the dispatch path: the channel is buffered(1)
// and read once, but a duplicate-id race could have already filled it —
// never block here while holding c.mu.
select {
case ch <- &Response{
JSONRPC: "2.0",
ID: id,
Error: &ResponseError{
Code: -32603, // InternalError
Message: "LSP client readLoop terminated",
},
}:
default:
}
delete(c.pending, id)
}
}
// IsRunning returns whether the client is running.
func (c *Client) IsRunning() bool {
c.runningMu.RLock()
defer c.runningMu.RUnlock()
return c.running
}
// setRunning sets the running flag under its lock.
func (c *Client) setRunning(v bool) {
c.runningMu.Lock()
c.running = v
c.runningMu.Unlock()
}
+66
View File
@@ -0,0 +1,66 @@
package lsp
import (
"context"
"os/exec"
"strings"
"testing"
"time"
)
// TestClientMarksNotRunningOnProcessExit verifies crash detection: when the
// underlying server process exits (stdout EOF), readLoop must flip the client to
// not-running so later Calls fail fast instead of blocking until their timeout.
func TestClientMarksNotRunningOnProcessExit(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 0") // exits immediately, closing stdout
c, err := NewClient(cmd)
if err != nil {
t.Skipf("cannot start helper process: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
deadline := time.Now().Add(2 * time.Second)
for c.IsRunning() && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if c.IsRunning() {
t.Fatal("client should be marked not running after its process exits")
}
// A Call on the dead client must fail fast (the running guard), not hang.
done := make(chan error, 1)
go func() {
_, callErr := c.Call(context.Background(), "test/method", nil)
done <- callErr
}()
select {
case callErr := <-done:
if callErr == nil {
t.Error("Call on a dead client should return an error")
}
case <-time.After(2 * time.Second):
t.Error("Call on a dead client hung instead of failing fast")
}
}
// 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)
}
})
}
}
+182 -48
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -15,7 +16,22 @@ import (
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
// LSP timeout and interval constants.
const (
// DefaultLSPTimeout is the default timeout for LSP requests.
DefaultLSPTimeout = 10 * time.Second
// DefaultIdleTimeout is the duration before idle LSP servers are reaped.
DefaultIdleTimeout = 5 * time.Minute
// ReaperInterval is how often the idle server reaper runs.
ReaperInterval = 60 * time.Second
// ShutdownTimeout is the timeout for graceful LSP server shutdown.
ShutdownTimeout = 2 * time.Second
)
// Manager manages LSP servers for different languages.
//
// Lock ordering: m.mu must always be acquired before srv.mu.
// Never acquire m.mu while holding srv.mu.
type Manager struct {
servers map[protocol.Language]*ManagedServer
logger *slog.Logger
@@ -24,6 +40,7 @@ type Manager struct {
timeout time.Duration
idleTimeout time.Duration
mu sync.RWMutex
closeOnce sync.Once
stopped bool
}
@@ -68,14 +85,32 @@ var DefaultServerConfigs = map[protocol.Language]ServerConfig{
protocol.LangCpp: {
Command: []string{"clangd"},
},
protocol.LangRust: {
Command: []string{"rust-analyzer"},
},
}
// AllowedLSPBinaries is a whitelist of allowed LSP server binary names.
// This prevents command injection by ensuring only known LSP servers can be executed.
var AllowedLSPBinaries = map[string]bool{
"gopls": true,
"typescript-language-server": true,
"pylsp": true,
"clangd": true,
// Common alternatives
"tsserver": true,
"pyright": true,
"ruff-lsp": true,
"rust-analyzer": true,
"ccls": true,
}
// NewManager creates a new LSP manager.
func NewManager(workspaceRoot string, logger *slog.Logger) *Manager {
m := &Manager{
servers: make(map[protocol.Language]*ManagedServer),
timeout: 10 * time.Second,
idleTimeout: 5 * time.Minute,
timeout: DefaultLSPTimeout,
idleTimeout: DefaultIdleTimeout,
workspaceRoot: workspaceRoot,
logger: logger,
stopReaper: make(chan struct{}),
@@ -93,7 +128,11 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
srv, exists := m.servers[lang]
m.mu.RUnlock()
if exists && srv.ready {
// A cached server is only reusable if it is both initialized and still
// running — a crashed language server stays in the map with ready==true, so
// without the liveness check every later call would be routed to a dead
// process and block until timeout.
if exists && srv.ready && srv.client.IsRunning() {
// Update lastUsed with server's own lock to avoid race condition
srv.mu.Lock()
srv.lastUsed = time.Now()
@@ -106,13 +145,21 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
defer m.mu.Unlock()
// Double-check after acquiring write lock
if srv, ok := m.servers[lang]; ok && srv.ready {
if srv, ok := m.servers[lang]; ok && srv.ready && srv.client.IsRunning() {
srv.mu.Lock()
srv.lastUsed = time.Now()
srv.mu.Unlock()
return srv, nil
}
// Evict a stale entry (crashed or never-ready) before spawning a replacement.
// Its process has already exited, so close it asynchronously to avoid holding
// m.mu on the (fast) cmd.Wait().
if stale, ok := m.servers[lang]; ok {
delete(m.servers, lang)
go func() { _ = stale.client.Close() }()
}
// Check if server config exists
config, ok := DefaultServerConfigs[lang]
if !ok {
@@ -127,12 +174,21 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
return nil, errors.NewLSPServerNotFound(string(lang), config.Command[0])
}
// Create command
// Validate command against whitelist to prevent command injection
binaryName := filepath.Base(cmdPath)
if !AllowedLSPBinaries[binaryName] {
return nil, errors.New(errors.ErrLSPServerNotFound, fmt.Sprintf("LSP binary %q is not in the allowed list", binaryName)).
WithContext("language", string(lang)).
WithContext("binary", binaryName).
WithRemediation("Only whitelisted LSP server binaries are allowed for security reasons")
}
// Create command — use exec.Command (not CommandContext) so the LSP
// subprocess is not killed when the request-scoped context expires.
args := append(config.Command[1:], config.Args...)
cmd := exec.CommandContext(ctx, cmdPath, args...)
cmd := exec.Command(cmdPath, args...)
cmd.Env = os.Environ()
cmd.Dir = m.workspaceRoot
// Create client
client, err := NewClient(cmd)
if err != nil {
@@ -153,20 +209,36 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
openDocs: make(map[string]int),
}
// Setup cleanup on failure - ensures resources are freed if initialization fails
var initialized bool
defer func() {
if !initialized {
_ = client.Close()
// Ensure process is killed on initialization failure
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
}()
// Initialize server
if err := m.initializeServer(ctx, newSrv); err != nil {
_ = client.Close()
// Ensure process is killed on initialization failure
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
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("command", config.Command[0]).
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
initialized = true
newSrv.ready = true
m.servers[lang] = newSrv
m.logger.Info("started LSP server", "language", lang, "command", config.Command[0])
@@ -304,17 +376,55 @@ func (m *Manager) Definition(ctx context.Context, file string, line, col int) ([
return nil, nil
}
// Result can be Location, []Location, or []LocationLink
var locations []Location
if err := json.Unmarshal(resp.Result, &locations); err != nil {
// Try single location
var single Location
if err := json.Unmarshal(resp.Result, &single); err == nil {
locations = []Location{single}
return parseDefinitionResult(resp.Result)
}
// parseDefinitionResult decodes a textDocument/definition result. Per LSP the
// result may be Location, Location[], LocationLink, or LocationLink[]. Because
// we advertise LinkSupport (see initialize), servers such as gopls and
// 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.
@@ -367,9 +477,13 @@ func (m *Manager) References(ctx context.Context, file string, line, col int, in
}
// ensureDocumentOpen opens a document if not already open.
func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, file string) error {
// It reads the file content outside the lock (to avoid holding the lock during I/O),
// then holds srv.mu for the entire check-and-send sequence to prevent duplicate didOpen
// notifications from concurrent goroutines.
func (m *Manager) ensureDocumentOpen(_ context.Context, srv *ManagedServer, file string) error {
uri := fileToURI(file)
// Quick check under lock — common fast path.
srv.mu.Lock()
if _, ok := srv.openDocs[uri]; ok {
srv.mu.Unlock()
@@ -377,15 +491,23 @@ func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, fi
}
srv.mu.Unlock()
// Read file content
// Read file content outside the lock to avoid holding it during I/O.
content, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Get language ID
langID := languageToLSPID(srv.language)
// Re-acquire lock and re-check to prevent TOCTOU race: two goroutines could
// both pass the fast-path check above and both try to send didOpen.
srv.mu.Lock()
defer srv.mu.Unlock()
if _, ok := srv.openDocs[uri]; ok {
return nil
}
params := DidOpenTextDocumentParams{
TextDocument: TextDocumentItem{
URI: uri,
@@ -399,10 +521,7 @@ func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, fi
return fmt.Errorf("didOpen failed: %w", err)
}
srv.mu.Lock()
srv.openDocs[uri] = 1
srv.mu.Unlock()
return nil
}
@@ -437,7 +556,7 @@ func (m *Manager) CloseDocument(_ context.Context, lang protocol.Language, file
// reapIdleServers periodically closes idle servers.
func (m *Manager) reapIdleServers() {
ticker := time.NewTicker(60 * time.Second)
ticker := time.NewTicker(ReaperInterval)
defer ticker.Stop()
for {
@@ -445,6 +564,10 @@ func (m *Manager) reapIdleServers() {
case <-m.stopReaper:
return
case <-ticker.C:
// Collect idle servers first to avoid holding the lock while closing
var toClose []*ManagedServer
var toCloseLanguages []protocol.Language
m.mu.Lock()
for lang, srv := range m.servers {
// Check lastUsed with server's lock to avoid race condition
@@ -453,36 +576,45 @@ func (m *Manager) reapIdleServers() {
srv.mu.Unlock()
if idle {
m.logger.Info("closing idle LSP server", "language", lang)
_ = srv.client.Close()
toClose = append(toClose, srv)
toCloseLanguages = append(toCloseLanguages, lang)
delete(m.servers, lang)
}
}
m.mu.Unlock()
// Close servers outside the lock to prevent deadlock
// (Close can block waiting for the process to exit)
for i, srv := range toClose {
m.logger.Info("closing idle LSP server", "language", toCloseLanguages[i])
_ = srv.client.Close()
}
}
}
}
// Close shuts down all LSP servers.
// Close shuts down all LSP servers. It is safe to call multiple times.
func (m *Manager) Close() error {
close(m.stopReaper)
m.closeOnce.Do(func() {
close(m.stopReaper)
m.mu.Lock()
defer m.mu.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
m.stopped = true
m.stopped = true
for lang, srv := range m.servers {
m.logger.Info("shutting down LSP server", "language", lang)
// Try graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_, _ = srv.client.Call(ctx, "shutdown", nil)
cancel()
_ = srv.client.Notify("exit", nil)
_ = srv.client.Close()
}
for lang, srv := range m.servers {
m.logger.Info("shutting down LSP server", "language", lang)
// Try graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
_, _ = srv.client.Call(ctx, "shutdown", nil)
cancel()
_ = srv.client.Notify("exit", nil)
_ = srv.client.Close()
}
m.servers = make(map[protocol.Language]*ManagedServer)
m.servers = make(map[protocol.Language]*ManagedServer)
})
return nil
}
@@ -497,13 +629,13 @@ func (m *Manager) IsAvailable(lang protocol.Language) bool {
return err == nil
}
// fileToURI converts a file path to a file URI.
// fileToURI converts a file path to a properly percent-encoded file URI.
func fileToURI(file string) string {
absPath, err := filepath.Abs(file)
if err != nil {
return "file://" + file
absPath = file
}
return "file://" + absPath
return (&url.URL{Scheme: "file", Path: absPath}).String()
}
// URIToFile converts a file URI to a file path.
@@ -529,6 +661,8 @@ func languageToLSPID(lang protocol.Language) string {
return "c"
case protocol.LangCpp:
return "cpp"
case protocol.LangRust:
return "rust"
default:
return string(lang)
}
+251
View File
@@ -1,7 +1,11 @@
package lsp
import (
"context"
"log/slog"
"os"
"testing"
"time"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
@@ -110,3 +114,250 @@ func TestDefaultServerConfigs(t *testing.T) {
}
}
}
// TestManagerTimeout tests timeout handling in LSP operations.
func TestManagerTimeout(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
// Verify timeout is set
if manager.timeout == 0 {
t.Error("manager timeout should not be zero")
}
// Verify default timeout is reasonable
if manager.timeout != 10*time.Second {
t.Errorf("expected default timeout of 10s, got %v", manager.timeout)
}
// Test that manager can handle short timeouts
manager.timeout = 1 * time.Millisecond
// Create a context that will timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
// Try to get a server with very short timeout - this should fail quickly
// Use a language that doesn't have an LSP server installed
_, err := manager.GetServer(ctx, "invalid_language")
if err == nil {
t.Log("GetServer with invalid language succeeded (LSP server may be installed)")
}
}
// TestManagerConnectionFailure tests handling of LSP connection failures.
func TestManagerConnectionFailure(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
ctx := context.Background()
// Test 1: Invalid language
_, err := manager.GetServer(ctx, "nonexistent_language")
if err == nil {
t.Error("expected error for nonexistent language")
}
// Test 2: Try to use LSP features without a valid server
// This should fail gracefully
_, err = manager.Hover(ctx, "/tmp/test.fake", 1, 1)
if err == nil {
t.Error("expected error for hover on unsupported language")
}
}
// TestManagerGracefulShutdown tests graceful shutdown of LSP servers.
func TestManagerGracefulShutdown(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
// Close should not panic even with no servers started
err := manager.Close()
if err != nil {
t.Errorf("Close() returned error: %v", err)
}
// Verify manager is stopped
if !manager.stopped {
t.Error("manager should be marked as stopped after Close()")
}
}
// TestManagerDoubleClose verifies that calling Close() twice does not panic (T-05, C-02).
func TestManagerDoubleClose(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
// First close should succeed
err := manager.Close()
if err != nil {
t.Errorf("first Close() returned error: %v", err)
}
// Second close must not panic (C-02 fix wraps close in sync.Once)
err = manager.Close()
if err != nil {
t.Errorf("second Close() returned error: %v", err)
}
if !manager.stopped {
t.Error("manager should be marked as stopped after double Close()")
}
}
// TestManagerIdleReaper tests the idle server cleanup mechanism.
func TestManagerIdleReaper(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
// Set a very short idle timeout for testing
manager.idleTimeout = 100 * time.Millisecond
// Verify idle timeout is set correctly
if manager.idleTimeout != 100*time.Millisecond {
t.Errorf("expected idle timeout of 100ms, got %v", manager.idleTimeout)
}
// The reaper goroutine should be running
// We can't easily test it without actually starting LSP servers,
// but we can verify it doesn't panic on close
time.Sleep(150 * time.Millisecond)
err := manager.Close()
if err != nil {
t.Errorf("Close() with active reaper returned error: %v", err)
}
}
// TestManagerDocumentManagement tests document open/close operations.
func TestManagerDocumentManagement(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
ctx := context.Background()
// Test closing a document for a non-existent server
err := manager.CloseDocument(ctx, protocol.LangGo, "/tmp/test.go")
if err != nil {
t.Errorf("CloseDocument on non-existent server should not error: %v", err)
}
// Test closing a document that was never opened
err = manager.CloseDocument(ctx, protocol.LangGo, "/tmp/test.go")
if err != nil {
t.Errorf("CloseDocument on unopened document should not error: %v", err)
}
}
// TestManagerConcurrentAccess tests concurrent access to the manager.
func TestManagerConcurrentAccess(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
// Test concurrent IsAvailable calls
const numGoroutines = 10
done := make(chan bool, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("panic in concurrent IsAvailable: %v", r)
}
done <- true
}()
// Call IsAvailable multiple times
for j := 0; j < 5; j++ {
_ = manager.IsAvailable(protocol.LangGo)
_ = manager.IsAvailable(protocol.LangPython)
_ = manager.IsAvailable(protocol.LangTypeScript)
}
}()
}
// Wait for all goroutines
for i := 0; i < numGoroutines; i++ {
<-done
}
}
// TestManagerErrorHandling tests various error conditions.
func TestManagerErrorHandling(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
ctx := context.Background()
tests := []struct {
name string
testFunc func() error
}{
{
name: "hover_on_nonexistent_file",
testFunc: func() error {
_, err := manager.Hover(ctx, "/nonexistent/file.go", 1, 1)
return err
},
},
{
name: "definition_on_nonexistent_file",
testFunc: func() error {
_, err := manager.Definition(ctx, "/nonexistent/file.go", 1, 1)
return err
},
},
{
name: "references_on_nonexistent_file",
testFunc: func() error {
_, err := manager.References(ctx, "/nonexistent/file.go", 1, 1, true)
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.testFunc()
if err == nil {
t.Log("operation succeeded (LSP server may be handling gracefully)")
}
// We don't require an error because behavior depends on whether
// the LSP server is installed and how it handles missing files
})
}
}
// TestManagerWorkspaceRoot tests workspace root handling.
func TestManagerWorkspaceRoot(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
if manager.workspaceRoot != tmpDir {
t.Errorf("expected workspace root %s, got %s", tmpDir, manager.workspaceRoot)
}
}
+469
View File
@@ -0,0 +1,469 @@
// Package metrics provides Prometheus-style metrics collection for the MCP server.
// It offers low-overhead, thread-safe metric types suitable for observability.
package metrics
import (
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// Counter is a monotonically increasing metric.
type Counter struct {
name string
help string
labels map[string]string
value atomic.Int64
}
// NewCounter creates a new counter metric.
func NewCounter(name, help string, labels map[string]string) *Counter {
return &Counter{
name: name,
help: help,
labels: labels,
}
}
// Inc increments the counter by 1.
func (c *Counter) Inc() {
c.value.Add(1)
}
// 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) {
if delta < 0 {
return
}
c.value.Add(delta)
}
// Value returns the current counter value.
func (c *Counter) Value() int64 {
return c.value.Load()
}
// Reset resets the counter to 0.
func (c *Counter) Reset() {
c.value.Store(0)
}
// Gauge is a metric that can go up or down.
type Gauge struct {
name string
help string
labels map[string]string
value atomic.Int64
}
// NewGauge creates a new gauge metric.
func NewGauge(name, help string, labels map[string]string) *Gauge {
return &Gauge{
name: name,
help: help,
labels: labels,
}
}
// Set sets the gauge to the given value.
func (g *Gauge) Set(val int64) {
g.value.Store(val)
}
// Inc increments the gauge by 1.
func (g *Gauge) Inc() {
g.value.Add(1)
}
// Dec decrements the gauge by 1.
func (g *Gauge) Dec() {
g.value.Add(-1)
}
// Add adds the given value to the gauge.
func (g *Gauge) Add(delta int64) {
g.value.Add(delta)
}
// Value returns the current gauge value.
func (g *Gauge) Value() int64 {
return g.value.Load()
}
// Histogram tracks the distribution of values in predefined buckets.
type Histogram struct {
name string
help string
labels map[string]string
buckets []float64
counts []atomic.Int64
sum atomic.Int64 // sum of all observed values (in nanoseconds for durations)
count atomic.Int64 // total count of observations
}
// DefaultDurationBuckets are default buckets for request durations (in seconds).
var DefaultDurationBuckets = []float64{
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
}
// NewHistogram creates a new histogram with the given buckets.
func NewHistogram(name, help string, labels map[string]string, buckets []float64) *Histogram {
if buckets == nil {
buckets = DefaultDurationBuckets
}
// Ensure buckets are sorted
sorted := make([]float64, len(buckets))
copy(sorted, buckets)
sort.Float64s(sorted)
h := &Histogram{
name: name,
help: help,
labels: labels,
buckets: sorted,
counts: make([]atomic.Int64, len(sorted)+1), // +1 for +Inf bucket
}
return h
}
// Observe records a value in the histogram.
func (h *Histogram) Observe(val float64) {
h.count.Add(1)
// Store sum in nanoseconds for precision with durations
h.sum.Add(int64(val * 1e9))
// Find bucket and increment
for i, bound := range h.buckets {
if val <= bound {
h.counts[i].Add(1)
return
}
}
// Value exceeds all buckets, add to +Inf
h.counts[len(h.buckets)].Add(1)
}
// ObserveDuration records a duration in seconds.
func (h *Histogram) ObserveDuration(d time.Duration) {
h.Observe(d.Seconds())
}
// Count returns the total number of observations.
func (h *Histogram) Count() int64 {
return h.count.Load()
}
// Sum returns the sum of all observations.
func (h *Histogram) Sum() float64 {
return float64(h.sum.Load()) / 1e9
}
// Registry holds all registered metrics.
type Registry struct {
mu sync.RWMutex
counters map[string]*Counter
gauges map[string]*Gauge
histograms map[string]*Histogram
}
// NewRegistry creates a new metrics registry.
func NewRegistry() *Registry {
return &Registry{
counters: make(map[string]*Counter),
gauges: make(map[string]*Gauge),
histograms: make(map[string]*Histogram),
}
}
// Counter returns or creates a counter with the given name.
func (r *Registry) Counter(name, help string, labels map[string]string) *Counter {
key := metricKey(name, labels)
r.mu.RLock()
if c, ok := r.counters[key]; ok {
r.mu.RUnlock()
return c
}
r.mu.RUnlock()
r.mu.Lock()
defer r.mu.Unlock()
// Double-check after acquiring write lock
if c, ok := r.counters[key]; ok {
return c
}
c := NewCounter(name, help, labels)
r.counters[key] = c
return c
}
// Gauge returns or creates a gauge with the given name.
func (r *Registry) Gauge(name, help string, labels map[string]string) *Gauge {
key := metricKey(name, labels)
r.mu.RLock()
if g, ok := r.gauges[key]; ok {
r.mu.RUnlock()
return g
}
r.mu.RUnlock()
r.mu.Lock()
defer r.mu.Unlock()
if g, ok := r.gauges[key]; ok {
return g
}
g := NewGauge(name, help, labels)
r.gauges[key] = g
return g
}
// Histogram returns or creates a histogram with the given name.
func (r *Registry) Histogram(name, help string, labels map[string]string, buckets []float64) *Histogram {
key := metricKey(name, labels)
r.mu.RLock()
if h, ok := r.histograms[key]; ok {
r.mu.RUnlock()
return h
}
r.mu.RUnlock()
r.mu.Lock()
defer r.mu.Unlock()
if h, ok := r.histograms[key]; ok {
return h
}
h := NewHistogram(name, help, labels, buckets)
r.histograms[key] = h
return h
}
// metricKey creates a unique key for a metric based on name and labels.
func metricKey(name string, labels map[string]string) string {
if len(labels) == 0 {
return name
}
var parts []string
for k, v := range labels {
parts = append(parts, fmt.Sprintf("%s=%q", k, v))
}
sort.Strings(parts)
return name + "{" + strings.Join(parts, ",") + "}"
}
// formatLabels formats labels for Prometheus output.
func formatLabels(labels map[string]string) string {
if len(labels) == 0 {
return ""
}
var parts []string
for k, v := range labels {
parts = append(parts, fmt.Sprintf("%s=%q", k, v))
}
sort.Strings(parts)
return "{" + strings.Join(parts, ",") + "}"
}
// Expose returns all metrics in Prometheus text format.
func (r *Registry) Expose() string {
r.mu.RLock()
defer r.mu.RUnlock()
var sb strings.Builder
// Export counters
for _, c := range r.counters {
if c.help != "" {
fmt.Fprintf(&sb, "# HELP %s %s\n", c.name, c.help)
}
fmt.Fprintf(&sb, "# TYPE %s counter\n", c.name)
fmt.Fprintf(&sb, "%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load())
}
// Export gauges
for _, g := range r.gauges {
if g.help != "" {
fmt.Fprintf(&sb, "# HELP %s %s\n", g.name, g.help)
}
fmt.Fprintf(&sb, "# TYPE %s gauge\n", g.name)
fmt.Fprintf(&sb, "%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load())
}
// Export histograms
for _, h := range r.histograms {
if h.help != "" {
fmt.Fprintf(&sb, "# HELP %s %s\n", h.name, h.help)
}
fmt.Fprintf(&sb, "# TYPE %s histogram\n", h.name)
// Cumulative bucket counts
var cumulative int64
for i, bound := range h.buckets {
cumulative += h.counts[i].Load()
labelStr := formatLabels(h.labels)
if labelStr == "" {
fmt.Fprintf(&sb, "%s_bucket{le=\"%g\"} %d\n", h.name, bound, cumulative)
} else {
// Insert le label into existing labels
fmt.Fprintf(&sb, "%s_bucket%s %d\n", h.name,
strings.Replace(labelStr, "}", fmt.Sprintf(",le=\"%g\"}", bound), 1), cumulative)
}
}
// +Inf bucket
cumulative += h.counts[len(h.buckets)].Load()
labelStr := formatLabels(h.labels)
if labelStr == "" {
fmt.Fprintf(&sb, "%s_bucket{le=\"+Inf\"} %d\n", h.name, cumulative)
} else {
fmt.Fprintf(&sb, "%s_bucket%s %d\n", h.name,
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative)
}
// Sum and count
fmt.Fprintf(&sb, "%s_sum%s %g\n", h.name, formatLabels(h.labels), h.Sum())
fmt.Fprintf(&sb, "%s_count%s %d\n", h.name, formatLabels(h.labels), h.count.Load())
}
return sb.String()
}
// Reset resets all metrics to zero.
func (r *Registry) Reset() {
r.mu.Lock()
defer r.mu.Unlock()
for _, c := range r.counters {
c.Reset()
}
for _, g := range r.gauges {
g.Set(0)
}
// Note: Histograms don't have a simple reset due to atomic bucket counts
}
// ServerMetrics provides pre-defined metrics for the MCP server.
type ServerMetrics struct {
registry *Registry
// Request metrics
RequestsTotal *Counter
RequestErrors *Counter
RequestDuration *Histogram
// Cache metrics
CacheHits *Counter
CacheMisses *Counter
// LSP metrics
ActiveLSPServers *Gauge
// Parse metrics
ParseDuration *Histogram
ParseErrors *Counter
}
// NewServerMetrics creates a new set of server metrics.
func NewServerMetrics() *ServerMetrics {
r := NewRegistry()
return &ServerMetrics{
registry: r,
RequestsTotal: r.Counter(
"mcp_requests_total",
"Total number of MCP requests processed",
nil,
),
RequestErrors: r.Counter(
"mcp_request_errors_total",
"Total number of MCP request errors",
nil,
),
RequestDuration: r.Histogram(
"mcp_request_duration_seconds",
"Request duration in seconds",
nil,
DefaultDurationBuckets,
),
CacheHits: r.Counter(
"mcp_cache_hits_total",
"Total number of cache hits",
nil,
),
CacheMisses: r.Counter(
"mcp_cache_misses_total",
"Total number of cache misses",
nil,
),
ActiveLSPServers: r.Gauge(
"mcp_lsp_servers_active",
"Number of active LSP server connections",
nil,
),
ParseDuration: r.Histogram(
"mcp_parse_duration_seconds",
"Parse duration in seconds",
nil,
[]float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0},
),
ParseErrors: r.Counter(
"mcp_parse_errors_total",
"Total number of parse errors",
nil,
),
}
}
// Expose returns all metrics in Prometheus text format.
func (m *ServerMetrics) Expose() string {
return m.registry.Expose()
}
// Registry returns the underlying metrics registry.
func (m *ServerMetrics) Registry() *Registry {
return m.registry
}
// RecordRequest records a request with its duration and error status.
func (m *ServerMetrics) RecordRequest(duration time.Duration, err error) {
m.RequestsTotal.Inc()
m.RequestDuration.ObserveDuration(duration)
if err != nil {
m.RequestErrors.Inc()
}
}
// RecordParse records a parse operation with its duration and error status.
func (m *ServerMetrics) RecordParse(duration time.Duration, err error) {
m.ParseDuration.ObserveDuration(duration)
if err != nil {
m.ParseErrors.Inc()
}
}
// RecordCacheHit records a cache hit.
func (m *ServerMetrics) RecordCacheHit() {
m.CacheHits.Inc()
}
// RecordCacheMiss records a cache miss.
func (m *ServerMetrics) RecordCacheMiss() {
m.CacheMisses.Inc()
}
// SetActiveLSPServers sets the number of active LSP servers.
func (m *ServerMetrics) SetActiveLSPServers(count int64) {
m.ActiveLSPServers.Set(count)
}
+571
View File
@@ -0,0 +1,571 @@
package metrics
import (
"strings"
"sync"
"testing"
"time"
)
func TestCounter(t *testing.T) {
tests := []struct {
name string
ops func(c *Counter)
expected int64
}{
{
name: "initial value is zero",
ops: func(c *Counter) {},
expected: 0,
},
{
name: "single inc",
ops: func(c *Counter) {
c.Inc()
},
expected: 1,
},
{
name: "multiple inc",
ops: func(c *Counter) {
c.Inc()
c.Inc()
c.Inc()
},
expected: 3,
},
{
name: "add positive",
ops: func(c *Counter) {
c.Add(10)
},
expected: 10,
},
{
name: "mixed operations",
ops: func(c *Counter) {
c.Inc()
c.Add(5)
c.Inc()
},
expected: 7,
},
{
name: "reset",
ops: func(c *Counter) {
c.Add(100)
c.Reset()
},
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := NewCounter("test_counter", "test help", nil)
tt.ops(c)
if got := c.Value(); got != tt.expected {
t.Errorf("Counter.Value() = %d, want %d", got, tt.expected)
}
})
}
}
// 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) {
c := NewCounter("concurrent_counter", "test", nil)
var wg sync.WaitGroup
numGoroutines := 100
incsPerGoroutine := 1000
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < incsPerGoroutine; j++ {
c.Inc()
}
}()
}
wg.Wait()
expected := int64(numGoroutines * incsPerGoroutine)
if got := c.Value(); got != expected {
t.Errorf("Counter.Value() = %d, want %d after concurrent increments", got, expected)
}
}
func TestGauge(t *testing.T) {
tests := []struct {
name string
ops func(g *Gauge)
expected int64
}{
{
name: "initial value is zero",
ops: func(g *Gauge) {},
expected: 0,
},
{
name: "set value",
ops: func(g *Gauge) {
g.Set(42)
},
expected: 42,
},
{
name: "inc",
ops: func(g *Gauge) {
g.Inc()
g.Inc()
},
expected: 2,
},
{
name: "dec",
ops: func(g *Gauge) {
g.Set(10)
g.Dec()
g.Dec()
},
expected: 8,
},
{
name: "add positive",
ops: func(g *Gauge) {
g.Add(5)
},
expected: 5,
},
{
name: "add negative",
ops: func(g *Gauge) {
g.Set(10)
g.Add(-3)
},
expected: 7,
},
{
name: "mixed operations",
ops: func(g *Gauge) {
g.Set(100)
g.Inc()
g.Dec()
g.Add(-50)
},
expected: 50,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewGauge("test_gauge", "test help", nil)
tt.ops(g)
if got := g.Value(); got != tt.expected {
t.Errorf("Gauge.Value() = %d, want %d", got, tt.expected)
}
})
}
}
func TestHistogram(t *testing.T) {
t.Run("default buckets", func(t *testing.T) {
h := NewHistogram("test_histogram", "test", nil, nil)
if len(h.buckets) != len(DefaultDurationBuckets) {
t.Errorf("expected default buckets, got %d buckets", len(h.buckets))
}
})
t.Run("custom buckets", func(t *testing.T) {
buckets := []float64{1.0, 5.0, 10.0}
h := NewHistogram("test_histogram", "test", nil, buckets)
if len(h.buckets) != 3 {
t.Errorf("expected 3 buckets, got %d", len(h.buckets))
}
})
t.Run("buckets are sorted", func(t *testing.T) {
buckets := []float64{10.0, 1.0, 5.0}
h := NewHistogram("test_histogram", "test", nil, buckets)
if h.buckets[0] != 1.0 || h.buckets[1] != 5.0 || h.buckets[2] != 10.0 {
t.Errorf("buckets not sorted: %v", h.buckets)
}
})
t.Run("observe values", func(t *testing.T) {
h := NewHistogram("test_histogram", "test", nil, []float64{1.0, 5.0, 10.0})
h.Observe(0.5) // goes to bucket 0 (<=1.0)
h.Observe(3.0) // goes to bucket 1 (<=5.0)
h.Observe(7.0) // goes to bucket 2 (<=10.0)
h.Observe(15.0) // goes to +Inf bucket
if h.Count() != 4 {
t.Errorf("expected count 4, got %d", h.Count())
}
})
t.Run("observe duration", func(t *testing.T) {
h := NewHistogram("test_histogram", "test", nil, []float64{0.001, 0.01, 0.1})
h.ObserveDuration(500 * time.Microsecond) // 0.0005s, goes to bucket 0
h.ObserveDuration(5 * time.Millisecond) // 0.005s, goes to bucket 1
if h.Count() != 2 {
t.Errorf("expected count 2, got %d", h.Count())
}
})
t.Run("sum tracking", func(t *testing.T) {
h := NewHistogram("test_histogram", "test", nil, []float64{1.0, 5.0, 10.0})
h.Observe(1.0)
h.Observe(2.0)
h.Observe(3.0)
expectedSum := 6.0
if got := h.Sum(); got != expectedSum {
t.Errorf("expected sum %f, got %f", expectedSum, got)
}
})
}
func TestRegistry(t *testing.T) {
t.Run("counter registration", func(t *testing.T) {
r := NewRegistry()
c1 := r.Counter("test_counter", "help", nil)
c2 := r.Counter("test_counter", "help", nil)
if c1 != c2 {
t.Error("expected same counter instance for same name")
}
})
t.Run("counter with labels", func(t *testing.T) {
r := NewRegistry()
labels1 := map[string]string{"method": "get"}
labels2 := map[string]string{"method": "post"}
c1 := r.Counter("http_requests", "help", labels1)
c2 := r.Counter("http_requests", "help", labels2)
if c1 == c2 {
t.Error("expected different counter instances for different labels")
}
})
t.Run("gauge registration", func(t *testing.T) {
r := NewRegistry()
g1 := r.Gauge("test_gauge", "help", nil)
g2 := r.Gauge("test_gauge", "help", nil)
if g1 != g2 {
t.Error("expected same gauge instance for same name")
}
})
t.Run("histogram registration", func(t *testing.T) {
r := NewRegistry()
h1 := r.Histogram("test_histogram", "help", nil, nil)
h2 := r.Histogram("test_histogram", "help", nil, nil)
if h1 != h2 {
t.Error("expected same histogram instance for same name")
}
})
}
func TestRegistryConcurrency(t *testing.T) {
r := NewRegistry()
var wg sync.WaitGroup
numGoroutines := 100
// Concurrent access to registry
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
c := r.Counter("concurrent_test", "test", nil)
c.Inc()
g := r.Gauge("concurrent_gauge", "test", nil)
g.Inc()
}(i)
}
wg.Wait()
c := r.Counter("concurrent_test", "test", nil)
if c.Value() != int64(numGoroutines) {
t.Errorf("expected counter value %d, got %d", numGoroutines, c.Value())
}
}
func TestRegistryExpose(t *testing.T) {
r := NewRegistry()
// Add some metrics
c := r.Counter("test_requests_total", "Total requests", nil)
c.Add(42)
g := r.Gauge("test_connections", "Active connections", nil)
g.Set(10)
h := r.Histogram("test_duration_seconds", "Request duration", nil, []float64{0.1, 0.5, 1.0})
h.Observe(0.05)
h.Observe(0.3)
h.Observe(0.8)
output := r.Expose()
// Check counter output
if !strings.Contains(output, "# TYPE test_requests_total counter") {
t.Error("expected counter type in output")
}
if !strings.Contains(output, "test_requests_total 42") {
t.Error("expected counter value in output")
}
// Check gauge output
if !strings.Contains(output, "# TYPE test_connections gauge") {
t.Error("expected gauge type in output")
}
if !strings.Contains(output, "test_connections 10") {
t.Error("expected gauge value in output")
}
// Check histogram output
if !strings.Contains(output, "# TYPE test_duration_seconds histogram") {
t.Error("expected histogram type in output")
}
if !strings.Contains(output, "test_duration_seconds_bucket") {
t.Error("expected histogram buckets in output")
}
if !strings.Contains(output, "test_duration_seconds_sum") {
t.Error("expected histogram sum in output")
}
if !strings.Contains(output, "test_duration_seconds_count") {
t.Error("expected histogram count in output")
}
}
func TestRegistryReset(t *testing.T) {
r := NewRegistry()
c := r.Counter("test_counter", "test", nil)
c.Add(100)
g := r.Gauge("test_gauge", "test", nil)
g.Set(50)
r.Reset()
if c.Value() != 0 {
t.Errorf("expected counter reset to 0, got %d", c.Value())
}
if g.Value() != 0 {
t.Errorf("expected gauge reset to 0, got %d", g.Value())
}
}
func TestServerMetrics(t *testing.T) {
t.Run("creation", func(t *testing.T) {
m := NewServerMetrics()
if m.RequestsTotal == nil {
t.Error("RequestsTotal should not be nil")
}
if m.RequestErrors == nil {
t.Error("RequestErrors should not be nil")
}
if m.RequestDuration == nil {
t.Error("RequestDuration should not be nil")
}
if m.CacheHits == nil {
t.Error("CacheHits should not be nil")
}
if m.CacheMisses == nil {
t.Error("CacheMisses should not be nil")
}
if m.ActiveLSPServers == nil {
t.Error("ActiveLSPServers should not be nil")
}
if m.ParseDuration == nil {
t.Error("ParseDuration should not be nil")
}
if m.ParseErrors == nil {
t.Error("ParseErrors should not be nil")
}
})
t.Run("record request success", func(t *testing.T) {
m := NewServerMetrics()
m.RecordRequest(100*time.Millisecond, nil)
if m.RequestsTotal.Value() != 1 {
t.Errorf("expected RequestsTotal 1, got %d", m.RequestsTotal.Value())
}
if m.RequestErrors.Value() != 0 {
t.Errorf("expected RequestErrors 0, got %d", m.RequestErrors.Value())
}
if m.RequestDuration.Count() != 1 {
t.Errorf("expected RequestDuration count 1, got %d", m.RequestDuration.Count())
}
})
t.Run("record request error", func(t *testing.T) {
m := NewServerMetrics()
m.RecordRequest(50*time.Millisecond, &testError{})
if m.RequestsTotal.Value() != 1 {
t.Errorf("expected RequestsTotal 1, got %d", m.RequestsTotal.Value())
}
if m.RequestErrors.Value() != 1 {
t.Errorf("expected RequestErrors 1, got %d", m.RequestErrors.Value())
}
})
t.Run("record parse", func(t *testing.T) {
m := NewServerMetrics()
m.RecordParse(10*time.Millisecond, nil)
m.RecordParse(5*time.Millisecond, &testError{})
if m.ParseDuration.Count() != 2 {
t.Errorf("expected ParseDuration count 2, got %d", m.ParseDuration.Count())
}
if m.ParseErrors.Value() != 1 {
t.Errorf("expected ParseErrors 1, got %d", m.ParseErrors.Value())
}
})
t.Run("record cache", func(t *testing.T) {
m := NewServerMetrics()
m.RecordCacheHit()
m.RecordCacheHit()
m.RecordCacheMiss()
if m.CacheHits.Value() != 2 {
t.Errorf("expected CacheHits 2, got %d", m.CacheHits.Value())
}
if m.CacheMisses.Value() != 1 {
t.Errorf("expected CacheMisses 1, got %d", m.CacheMisses.Value())
}
})
t.Run("set active LSP servers", func(t *testing.T) {
m := NewServerMetrics()
m.SetActiveLSPServers(5)
if m.ActiveLSPServers.Value() != 5 {
t.Errorf("expected ActiveLSPServers 5, got %d", m.ActiveLSPServers.Value())
}
m.SetActiveLSPServers(3)
if m.ActiveLSPServers.Value() != 3 {
t.Errorf("expected ActiveLSPServers 3, got %d", m.ActiveLSPServers.Value())
}
})
t.Run("expose", func(t *testing.T) {
m := NewServerMetrics()
m.RecordRequest(100*time.Millisecond, nil)
output := m.Expose()
if !strings.Contains(output, "mcp_requests_total") {
t.Error("expected mcp_requests_total in output")
}
if !strings.Contains(output, "mcp_request_duration_seconds") {
t.Error("expected mcp_request_duration_seconds in output")
}
})
}
func TestMetricKey(t *testing.T) {
tests := []struct {
name string
labels map[string]string
expected string
}{
{
name: "no labels",
labels: nil,
expected: "test_metric",
},
{
name: "single label",
labels: map[string]string{"method": "get"},
expected: `test_metric{method="get"}`,
},
{
name: "multiple labels sorted",
labels: map[string]string{"method": "get", "code": "200"},
expected: `test_metric{code="200",method="get"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := metricKey("test_metric", tt.labels)
if got != tt.expected {
t.Errorf("metricKey() = %q, want %q", got, tt.expected)
}
})
}
}
func TestFormatLabels(t *testing.T) {
tests := []struct {
name string
labels map[string]string
expected string
}{
{
name: "no labels",
labels: nil,
expected: "",
},
{
name: "single label",
labels: map[string]string{"method": "get"},
expected: `{method="get"}`,
},
{
name: "multiple labels sorted",
labels: map[string]string{"method": "get", "code": "200"},
expected: `{code="200",method="get"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatLabels(tt.labels)
if got != tt.expected {
t.Errorf("formatLabels() = %q, want %q", got, tt.expected)
}
})
}
}
// testError is a simple error type for testing
type testError struct{}
func (e *testError) Error() string { return "test error" }
+8
View File
@@ -16,6 +16,14 @@ func FindNodeAtPosition(tree *sitter.Tree, line, col int) *sitter.Node {
return nil
}
// Positions are 1-indexed; reject non-positive values so the uint32
// conversion below can't underflow to a huge Row/Column (line/col == 0
// would wrap to 4294967295). Reachable from the LSP-down AST fallback with
// caller-supplied coordinates.
if line < 1 || col < 1 {
return nil
}
// Convert to 0-indexed
point := sitter.Point{
Row: uint32(line - 1), // #nosec G115 - line numbers are bounded by file size
+45
View File
@@ -0,0 +1,45 @@
package parser
import (
"context"
"testing"
)
func TestFindNodeAtPosition(t *testing.T) {
r := NewRegistry()
content := []byte("package main\n\nfunc Hello() string { return \"hi\" }\n")
res, err := r.Parse(context.Background(), "test.go", content)
if err != nil {
t.Fatalf("parse: %v", err)
}
t.Run("nil tree", func(t *testing.T) {
if FindNodeAtPosition(nil, 3, 6) != nil {
t.Error("nil tree must return nil")
}
})
// Guard against the uint32 underflow: non-positive coordinates must return
// nil, never wrap to a huge Row/Column.
t.Run("non-positive coordinates", func(t *testing.T) {
for _, c := range []struct{ line, col int }{{0, 0}, {0, 5}, {3, 0}, {-1, 4}} {
if got := FindNodeAtPosition(res.Tree, c.line, c.col); got != nil {
t.Errorf("FindNodeAtPosition(tree, %d, %d) = %v, want nil", c.line, c.col, got.Type())
}
}
})
t.Run("out of range line returns nil", func(t *testing.T) {
if got := FindNodeAtPosition(res.Tree, 99999, 1); got != nil {
t.Errorf("far-out-of-range line should return nil, got %v", got.Type())
}
})
t.Run("valid position returns a node", func(t *testing.T) {
// Line 3, column 6 lands inside "Hello".
got := FindNodeAtPosition(res.Tree, 3, 6)
if got == nil {
t.Fatal("expected a node at a valid in-range position, got nil")
}
})
}
+8 -6
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"testing"
"github.com/cespare/xxhash/v2"
)
// TestLRUCacheEviction tests that the LRU cache properly evicts old entries.
@@ -82,8 +84,8 @@ func TestContentHashCollisionResistance(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
hash1 := contentHash(tc.content1)
hash2 := contentHash(tc.content2)
hash1 := fmt.Sprintf("%016x", xxhash.Sum64(tc.content1))
hash2 := fmt.Sprintf("%016x", xxhash.Sum64(tc.content2))
if hash1 == hash2 {
t.Errorf("Hash collision: %s == %s for different content", hash1, hash2)
@@ -96,9 +98,9 @@ func TestContentHashCollisionResistance(t *testing.T) {
func TestContentHashConsistency(t *testing.T) {
content := []byte("package main\n\nfunc test() {}\n")
hash1 := contentHash(content)
hash2 := contentHash(content)
hash3 := contentHash(content)
hash1 := fmt.Sprintf("%016x", xxhash.Sum64(content))
hash2 := fmt.Sprintf("%016x", xxhash.Sum64(content))
hash3 := fmt.Sprintf("%016x", xxhash.Sum64(content))
if hash1 != hash2 || hash2 != hash3 {
t.Errorf("Hash inconsistency: %s, %s, %s", hash1, hash2, hash3)
@@ -115,7 +117,7 @@ func BenchmarkContentHash_xxHash(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = contentHash(content)
_ = fmt.Sprintf("%016x", xxhash.Sum64(content))
}
}
+176 -2
View File
@@ -3,6 +3,7 @@ package parser
import (
"regexp"
"sort"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
@@ -46,6 +47,10 @@ func ExtractDocComment(n *sitter.Node, content []byte, lang protocol.Language) *
return extractPythonDocComment(n, content)
case protocol.LangC, protocol.LangCpp:
return extractCDocComment(n, content)
case protocol.LangElixir:
return extractElixirDocComment(n, content)
case protocol.LangRust:
return extractRustDocComment(n, content)
default:
return nil
}
@@ -526,11 +531,16 @@ func FormatDocComment(doc *DocComment) string {
written[t] = true
}
for tagName, val := range doc.Tags {
remaining := make([]string, 0, len(doc.Tags))
for tagName := range doc.Tags {
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())
@@ -548,3 +558,167 @@ func cleanPythonDocstring(doc string) string {
return strings.TrimSpace(doc)
}
// extractRustDocComment extracts Rust documentation comments (/// style).
func extractRustDocComment(n *sitter.Node, content []byte) *DocComment {
comments := collectPrecedingComments(n, content, []string{"line_comment"})
if len(comments) == 0 {
return nil
}
// Filter for /// doc comments only
var docComments []*sitter.Node
for _, c := range comments {
text := GetNodeText(c, content)
trimmed := strings.TrimSpace(text)
if strings.HasPrefix(trimmed, "///") {
docComments = append(docComments, c)
}
}
if len(docComments) == 0 {
return nil
}
var parts []string
var raw []string
startLine := -1
endLine := -1
for _, c := range docComments {
text := GetNodeText(c, content)
raw = append(raw, text)
if startLine == -1 {
startLine = int(c.StartPoint().Row) + 1
}
endLine = int(c.EndPoint().Row) + 1
// Clean /// prefix
cleaned := strings.TrimSpace(text)
cleaned = strings.TrimPrefix(cleaned, "///")
if len(cleaned) > 0 && cleaned[0] == ' ' {
cleaned = cleaned[1:]
}
parts = append(parts, cleaned)
}
if len(parts) == 0 {
return nil
}
return &DocComment{
Text: strings.Join(parts, "\n"),
Raw: strings.Join(raw, "\n"),
Style: CommentStyleDoxygen,
Tags: nil,
StartLine: startLine,
EndLine: endLine,
}
}
// extractElixirDocComment extracts Elixir documentation from @doc and @moduledoc attributes.
// Elixir uses module attributes like @doc and @moduledoc for documentation.
func extractElixirDocComment(n *sitter.Node, content []byte) *DocComment {
// Look for @doc or @moduledoc attribute preceding this node
prev := n.PrevSibling()
for prev != nil {
// Check if this is an unary_operator with @ (module attribute)
if prev.Type() == "unary_operator" {
text := GetNodeText(prev, content)
trimmed := strings.TrimSpace(text)
// Check for @doc or @moduledoc
if strings.HasPrefix(trimmed, "@doc") || strings.HasPrefix(trimmed, "@moduledoc") {
// Extract the documentation string
docText := extractElixirDocString(prev, content)
if docText != "" {
return &DocComment{
Text: docText,
Raw: text,
Style: CommentStyleDocstring,
Tags: nil,
StartLine: int(prev.StartPoint().Row) + 1,
EndLine: int(prev.EndPoint().Row) + 1,
}
}
}
}
// Also check for regular # comments
if prev.Type() == "comment" {
comments := collectPrecedingComments(n, content, []string{"comment"})
if len(comments) > 0 {
var parts []string
var raw []string
startLine := -1
endLine := -1
for _, c := range comments {
text := GetNodeText(c, content)
raw = append(raw, text)
if startLine == -1 {
startLine = int(c.StartPoint().Row) + 1
}
endLine = int(c.EndPoint().Row) + 1
// Clean # comment
cleaned := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(text), "#"))
if cleaned != "" {
parts = append(parts, cleaned)
}
}
if len(parts) > 0 {
return &DocComment{
Text: strings.Join(parts, "\n"),
Raw: strings.Join(raw, "\n"),
Style: CommentStyleHash,
Tags: nil,
StartLine: startLine,
EndLine: endLine,
}
}
}
break
}
prev = prev.PrevSibling()
}
return nil
}
// extractElixirDocString extracts the documentation string from an Elixir @doc/@moduledoc attribute.
func extractElixirDocString(n *sitter.Node, content []byte) string {
// The doc attribute typically looks like:
// @doc """
// Documentation here
// """
// or
// @doc "Single line doc"
text := GetNodeText(n, content)
// Find the string content after @doc or @moduledoc
var docContent string
// Check for heredoc style (triple quotes)
if idx := strings.Index(text, `"""`); idx != -1 {
// Find the closing triple quotes
rest := text[idx+3:]
if endIdx := strings.Index(rest, `"""`); endIdx != -1 {
docContent = rest[:endIdx]
}
} else if idx := strings.Index(text, `"`); idx != -1 {
// Single quoted string
rest := text[idx+1:]
if endIdx := strings.Index(rest, `"`); endIdx != -1 {
docContent = rest[:endIdx]
}
}
return strings.TrimSpace(docContent)
}
+4
View File
@@ -104,6 +104,7 @@ func NoComment() {}
if doc == nil {
t.Fatal("expected doc, got nil")
return
}
if doc.Text != tt.wantText {
@@ -192,6 +193,7 @@ function validate(name) {}
if doc == nil {
t.Fatal("expected doc, got nil")
return
}
if doc.Text != tt.wantText {
@@ -291,6 +293,7 @@ func TestExtractPythonDocComment(t *testing.T) {
if doc == nil {
t.Fatal("expected doc, got nil")
return
}
if doc.Text != tt.wantText {
@@ -372,6 +375,7 @@ int simple() { return 1; }
if doc == nil {
t.Fatal("expected doc, got nil")
return
}
if doc.Text != tt.wantText {
+85 -28
View File
@@ -5,37 +5,56 @@ import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/cespare/xxhash/v2"
lru "github.com/hashicorp/golang-lru/v2"
sitter "github.com/smacker/go-tree-sitter"
"github.com/smacker/go-tree-sitter/c"
"github.com/smacker/go-tree-sitter/cpp"
"github.com/smacker/go-tree-sitter/elixir"
"github.com/smacker/go-tree-sitter/golang"
"github.com/smacker/go-tree-sitter/html"
"github.com/smacker/go-tree-sitter/javascript"
"github.com/smacker/go-tree-sitter/python"
"github.com/smacker/go-tree-sitter/rust"
"github.com/smacker/go-tree-sitter/typescript/typescript"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
// MaxFileSize is the maximum file size we'll parse (10MB).
const MaxFileSize = 10 * 1024 * 1024
// maxFileSize is the default maximum file size we'll parse (10MB).
const maxFileSize = 10 * 1024 * 1024
// Registry manages Tree-sitter parsers for different languages.
type Registry struct {
parsers map[protocol.Language]*sitter.Parser
cache *lru.Cache[string, *CachedTree]
mu sync.RWMutex
parsers map[protocol.Language]*sitter.Parser
cache *lru.Cache[string, *CachedTree]
maxParseSize int64
mu sync.RWMutex
parserMu sync.Map // per-language mutexes for parse serialization
closed bool // Indicates if the registry has been closed
// Cache metrics (atomic for thread-safety)
cacheHits atomic.Int64
cacheMisses atomic.Int64
// Parse duration tracking
totalParseTime atomic.Int64 // nanoseconds
parseCount atomic.Int64
lastParseDuration atomic.Int64 // nanoseconds
}
// 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 {
Tree *sitter.Tree
Language protocol.Language
Errors []SyntaxError
}
// ParseResult contains the result of parsing a file.
@@ -53,8 +72,15 @@ type SyntaxError struct {
Location protocol.Location
}
// NewRegistry creates a new parser registry.
// NewRegistry creates a new parser registry with the default max parse size.
// For custom max parse size, use NewRegistryWithSize.
func NewRegistry() *Registry {
return NewRegistryWithSize(0)
}
// NewRegistryWithSize creates a new parser registry with the specified max parse size.
// If maxParseSize is 0 or negative, uses the default MaxFileSize constant.
func NewRegistryWithSize(maxParseSize int64) *Registry {
// Create LRU cache with capacity of 100 trees
cache, err := lru.New[string, *CachedTree](100)
if err != nil {
@@ -62,9 +88,14 @@ func NewRegistry() *Registry {
panic(fmt.Sprintf("failed to create LRU cache: %v", err))
}
if maxParseSize <= 0 {
maxParseSize = maxFileSize
}
return &Registry{
parsers: make(map[protocol.Language]*sitter.Parser),
cache: cache,
parsers: make(map[protocol.Language]*sitter.Parser),
cache: cache,
maxParseSize: maxParseSize,
}
}
@@ -88,16 +119,25 @@ func getLanguage(lang protocol.Language) (*sitter.Language, error) {
case protocol.LangVue:
// Vue SFC files use HTML-like template syntax, so we use the HTML parser
return html.GetLanguage(), nil
case protocol.LangElixir:
return elixir.GetLanguage(), nil
case protocol.LangRust:
return rust.GetLanguage(), nil
default:
return nil, errors.New(errors.ErrInvalidLanguage, fmt.Sprintf("language %s is not supported", lang)).
WithContext("language", string(lang)).
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue")
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir, Rust")
}
}
// GetParser returns a parser for the given language.
// Returns an error if the registry has been closed.
func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
r.mu.RLock()
if r.closed {
r.mu.RUnlock()
return nil, errors.New(errors.ErrInternal, "parser registry is closed")
}
if p, ok := r.parsers[lang]; ok {
r.mu.RUnlock()
return p, nil
@@ -108,6 +148,11 @@ func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
r.mu.Lock()
defer r.mu.Unlock()
// Check closed again after acquiring write lock
if r.closed {
return nil, errors.New(errors.ErrInternal, "parser registry is closed")
}
// Double-check after acquiring write lock
if p, ok := r.parsers[lang]; ok {
return p, nil
@@ -127,9 +172,9 @@ func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
// Parse parses the given content for the specified language.
func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
// Check file size
if len(content) > MaxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
// Check file size against configured limit
if int64(len(content)) > r.maxParseSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), r.maxParseSize)
}
// Detect binary files
@@ -156,16 +201,18 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
}
// Check cache (LRU cache is thread-safe)
hash := contentHash(content)
// Include language in cache key to prevent cross-language collisions
hash := fmt.Sprintf("%s:%016x", string(lang), xxhash.Sum64(content))
if cached, ok := r.cache.Get(hash); ok && cached.Language == lang {
errors := extractErrors(cached.Tree.RootNode(), content)
r.cacheHits.Add(1)
return &ParseResult{
Tree: cached.Tree,
Language: lang,
Errors: errors,
Errors: cached.Errors,
Content: content,
}, nil
}
r.cacheMisses.Add(1)
// Get parser
parser, err := r.GetParser(lang)
@@ -173,11 +220,22 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
return nil, err
}
// Parse content - tree-sitter parsers are not thread-safe,
// so we need to hold the lock during parsing
r.mu.Lock()
// Parse content - tree-sitter parsers are not thread-safe per instance,
// but parsers for different languages are independent.
// Use per-language locks to allow concurrent parsing of different languages.
muVal, _ := r.parserMu.LoadOrStore(lang, &sync.Mutex{})
langMu := muVal.(*sync.Mutex)
start := time.Now()
langMu.Lock()
tree, err := parser.ParseCtx(ctx, nil, content)
r.mu.Unlock()
langMu.Unlock()
duration := time.Since(start)
// Update duration metrics
r.totalParseTime.Add(duration.Nanoseconds())
r.parseCount.Add(1)
r.lastParseDuration.Store(duration.Nanoseconds())
if err != nil {
return nil, errors.NewParseError(string(lang), filename, err)
@@ -186,10 +244,12 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
// Extract syntax errors
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{
Tree: tree,
Language: lang,
Errors: errors,
})
return &ParseResult{
@@ -236,13 +296,6 @@ func extractErrors(node *sitter.Node, _ []byte) []SyntaxError {
return errors
}
// contentHash returns a fast hash of the content for caching.
// Uses xxHash which is 5-10x faster than SHA256 for non-cryptographic purposes.
func contentHash(content []byte) string {
h := xxhash.Sum64(content)
return fmt.Sprintf("%016x", h)
}
// isBinary checks if content appears to be binary.
func isBinary(content []byte) bool {
// Check first 8000 bytes for null bytes
@@ -257,10 +310,14 @@ func isBinary(content []byte) bool {
}
// Close closes all parsers and clears the cache.
// After Close is called, the registry cannot be used for parsing.
func (r *Registry) Close() {
r.mu.Lock()
defer r.mu.Unlock()
// Mark as closed first to prevent new parse operations
r.closed = true
for _, p := range r.parsers {
p.Close()
}
+462
View File
@@ -0,0 +1,462 @@
package parser
import (
"context"
"fmt"
"strings"
"testing"
"github.com/cespare/xxhash/v2"
)
// BenchmarkParse benchmarks parsing files of various sizes.
func BenchmarkParse(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
benchmarks := []struct {
name string
content string
}{
{
name: "small_file_100_lines",
content: generateGoCode(100),
},
{
name: "medium_file_1000_lines",
content: generateGoCode(1000),
},
{
name: "large_file_5000_lines",
content: generateGoCode(5000),
},
{
name: "very_large_file_10000_lines",
content: generateGoCode(10000),
},
}
for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
content := []byte(bm.content)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
})
}
}
// BenchmarkParseCacheHit benchmarks cache hit performance.
func BenchmarkParseCacheHit(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
content := []byte(generateGoCode(1000))
// Warm up the cache
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("initial parse failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
}
// BenchmarkParseCacheMiss benchmarks cache miss performance.
func BenchmarkParseCacheMiss(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Use different content each time to force cache miss
content := []byte(generateGoCodeWithSuffix(1000, i))
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
}
// BenchmarkParseLanguages benchmarks parsing different language files.
func BenchmarkParseLanguages(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
languages := []struct {
name string
filename string
content string
}{
{
name: "go",
filename: "test.go",
content: generateGoCode(500),
},
{
name: "typescript",
filename: "test.ts",
content: generateTypeScriptCode(500),
},
{
name: "python",
filename: "test.py",
content: generatePythonCode(500),
},
{
name: "javascript",
filename: "test.js",
content: generateJavaScriptCode(500),
},
}
for _, lang := range languages {
b.Run(lang.name, func(b *testing.B) {
content := []byte(lang.content)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := registry.Parse(ctx, lang.filename, content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
})
}
}
// BenchmarkParseComplexity benchmarks parsing files with varying complexity.
func BenchmarkParseComplexity(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
benchmarks := []struct {
name string
content string
}{
{
name: "simple_functions",
content: generateSimpleFunctions(100),
},
{
name: "nested_structures",
content: generateNestedStructures(50),
},
{
name: "complex_types",
content: generateComplexTypes(50),
},
}
for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
content := []byte(bm.content)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
})
}
}
// BenchmarkContentHash benchmarks the content hashing function.
func BenchmarkContentHash(b *testing.B) {
sizes := []int{100, 1000, 10000, 100000}
for _, size := range sizes {
b.Run(formatSize(size), func(b *testing.B) {
content := []byte(strings.Repeat("a", size))
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = fmt.Sprintf("%016x", xxhash.Sum64(content))
}
})
}
}
// BenchmarkIsBinary benchmarks the binary detection function.
func BenchmarkIsBinary(b *testing.B) {
sizes := []int{100, 1000, 8000, 10000}
for _, size := range sizes {
b.Run(formatSize(size)+"_text", func(b *testing.B) {
content := []byte(strings.Repeat("Hello, World!\n", size/14))
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = isBinary(content)
}
})
b.Run(formatSize(size)+"_binary", func(b *testing.B) {
content := make([]byte, size)
for j := 0; j < size; j++ {
content[j] = byte(j % 256)
}
content[size/2] = 0 // Add null byte
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = isBinary(content)
}
})
}
}
// BenchmarkParseWithMaxSize benchmarks parsing with different max size limits.
func BenchmarkParseWithMaxSize(b *testing.B) {
ctx := context.Background()
limits := []int64{
10 * 1024, // 10KB
100 * 1024, // 100KB
1024 * 1024, // 1MB
10 * 1024 * 1024, // 10MB
}
content := []byte(generateGoCode(500))
for _, limit := range limits {
b.Run(formatBytes(limit), func(b *testing.B) {
// Skip if content is larger than limit
if int64(len(content)) > limit {
b.Skipf("content size %d exceeds limit %d", len(content), limit)
}
registry := NewRegistryWithSize(limit)
defer registry.Close()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
})
}
}
// BenchmarkConcurrentParse benchmarks concurrent parsing operations.
func BenchmarkConcurrentParse(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
content := []byte(generateGoCode(500))
b.ResetTimer()
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
})
}
// Helper functions to generate test code
func generateGoCode(lines int) string {
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < lines/10; i++ {
sb.WriteString("func Function")
sb.WriteString(itoa(i))
sb.WriteString("(a, b int) int {\n")
sb.WriteString("\tif a > b {\n")
sb.WriteString("\t\treturn a + b\n")
sb.WriteString("\t}\n")
sb.WriteString("\treturn a - b\n")
sb.WriteString("}\n\n")
}
return sb.String()
}
func generateGoCodeWithSuffix(lines int, suffix int) string {
code := generateGoCode(lines)
return code + "// Suffix: " + itoa(suffix) + "\n"
}
func generateTypeScriptCode(lines int) string {
var sb strings.Builder
for i := 0; i < lines/8; i++ {
sb.WriteString("function function")
sb.WriteString(itoa(i))
sb.WriteString("(a: number, b: number): number {\n")
sb.WriteString(" if (a > b) {\n")
sb.WriteString(" return a + b;\n")
sb.WriteString(" }\n")
sb.WriteString(" return a - b;\n")
sb.WriteString("}\n\n")
}
return sb.String()
}
func generatePythonCode(lines int) string {
var sb strings.Builder
for i := 0; i < lines/6; i++ {
sb.WriteString("def function")
sb.WriteString(itoa(i))
sb.WriteString("(a, b):\n")
sb.WriteString(" if a > b:\n")
sb.WriteString(" return a + b\n")
sb.WriteString(" return a - b\n\n")
}
return sb.String()
}
func generateJavaScriptCode(lines int) string {
var sb strings.Builder
for i := 0; i < lines/8; i++ {
sb.WriteString("function function")
sb.WriteString(itoa(i))
sb.WriteString("(a, b) {\n")
sb.WriteString(" if (a > b) {\n")
sb.WriteString(" return a + b;\n")
sb.WriteString(" }\n")
sb.WriteString(" return a - b;\n")
sb.WriteString("}\n\n")
}
return sb.String()
}
func generateSimpleFunctions(count int) string {
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < count; i++ {
sb.WriteString("func Func")
sb.WriteString(itoa(i))
sb.WriteString("() { }\n\n")
}
return sb.String()
}
func generateNestedStructures(depth int) string {
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < depth; i++ {
sb.WriteString("type Struct")
sb.WriteString(itoa(i))
sb.WriteString(" struct {\n")
sb.WriteString("\tField1 int\n")
sb.WriteString("\tField2 string\n")
if i > 0 {
sb.WriteString("\tNested Struct")
sb.WriteString(itoa(i - 1))
sb.WriteString("\n")
}
sb.WriteString("}\n\n")
}
return sb.String()
}
func generateComplexTypes(count int) string {
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < count; i++ {
sb.WriteString("type Type")
sb.WriteString(itoa(i))
sb.WriteString(" interface {\n")
sb.WriteString("\tMethod1() error\n")
sb.WriteString("\tMethod2(a int, b string) (int, error)\n")
sb.WriteString("\tMethod3() chan interface{}\n")
sb.WriteString("}\n\n")
}
return sb.String()
}
func formatSize(size int) string {
if size < 1000 {
return itoa(size) + "B"
}
return itoa(size/1000) + "KB"
}
func formatBytes(bytes int64) string {
if bytes < 1024 {
return itoa(int(bytes)) + "B"
}
if bytes < 1024*1024 {
return itoa(int(bytes/1024)) + "KB"
}
return itoa(int(bytes/(1024*1024))) + "MB"
}
// Simple integer to string conversion without importing strconv
func itoa(n int) string {
if n == 0 {
return "0"
}
negative := n < 0
if negative {
n = -n
}
var buf [20]byte
i := len(buf) - 1
for n > 0 {
buf[i] = byte('0' + n%10)
n /= 10
i--
}
if negative {
buf[i] = '-'
i--
}
return string(buf[i+1:])
}
+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) {
tests := []struct {
name string
+373
View File
@@ -0,0 +1,373 @@
// Package parser provides skeleton rendering for source files.
package parser
import (
"context"
"fmt"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
sitter "github.com/smacker/go-tree-sitter"
)
// SkeletonFile returns a skeleton representation of the file: top-level declarations
// with signatures and doc-comments intact, but function/method bodies replaced with
// a language-appropriate placeholder.
//
// Supported: go, typescript, javascript, python, rust.
// Other languages fall back to symbols_only (AST summary text).
// Returns (skeletonText, isFullSkeleton, error).
func SkeletonFile(ctx context.Context, reg *Registry, filename string, content []byte) (string, bool, error) {
result, err := reg.Parse(ctx, filename, content)
if err != nil {
return "", false, err
}
lang := protocol.DetectLanguage(filename)
switch lang {
case protocol.LangGo:
return skeletonGo(result.Tree, content), true, nil
case protocol.LangTypeScript, protocol.LangJavaScript:
return skeletonTS(result.Tree, content), true, nil
case protocol.LangPython:
return skeletonPython(result.Tree, content), true, nil
case protocol.LangRust:
return skeletonRust(result.Tree, content), true, nil
default:
// TODO: skeleton for c, cpp, elixir, html, vue — fall back to symbols_only
syms := ExtractSymbols(result.Tree, content, lang, filename)
return renderSymbolsOnly(syms, lang), false, nil
}
}
// renderSymbolsOnly renders a compact symbol list (fallback for unsupported
// languages). It deliberately does not fabricate "<source line> { ... }" output:
// 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
fmt.Fprintf(&sb, "symbols only (no skeleton for %s):\n", lang)
for _, s := range syms {
fmt.Fprintf(&sb, "%s %s :%d\n", s.Kind, s.Name, s.Location.Line)
if s.Doc != "" {
fmt.Fprintf(&sb, " doc: %s\n", s.Doc)
}
}
return sb.String()
}
// ---- Go skeleton ----
// skeletonGoBodyNodes lists Go node types that have a body field to replace.
var skeletonGoBodyNodes = map[string]string{
"function_declaration": "body",
"method_declaration": "body",
}
func skeletonGo(tree *sitter.Tree, content []byte) string {
if tree == nil {
return string(content)
}
root := tree.RootNode()
var sb strings.Builder
skeletonGoNode(root, content, &sb)
return sb.String()
}
func skeletonGoNode(node *sitter.Node, content []byte, sb *strings.Builder) {
if node == nil {
return
}
nodeType := node.Type()
if bodyField, ok := skeletonGoBodyNodes[nodeType]; ok {
body := node.ChildByFieldName(bodyField)
if body != nil {
sig := strings.TrimRight(string(content[node.StartByte():body.StartByte()]), " \t")
sb.WriteString(sig)
sb.WriteString("{ ... }\n\n")
return
}
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
return
}
switch nodeType {
case "source_file":
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(i)
if child == nil {
continue
}
skeletonGoNode(child, content, sb)
}
return
case "comment":
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
return
default:
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
}
}
// ---- TypeScript / JavaScript skeleton ----
func skeletonTS(tree *sitter.Tree, content []byte) string {
if tree == nil {
return string(content)
}
root := tree.RootNode()
var sb strings.Builder
skeletonTSNode(root, content, &sb)
return sb.String()
}
func skeletonTSNode(node *sitter.Node, content []byte, sb *strings.Builder) {
if node == nil {
return
}
nodeType := node.Type()
switch nodeType {
case "program":
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(i)
if child == nil {
continue
}
skeletonTSNode(child, content, sb)
}
return
case "function_declaration":
body := node.ChildByFieldName("body")
if body != nil {
sig := strings.TrimRight(string(content[node.StartByte():body.StartByte()]), " \t")
sb.WriteString(sig)
sb.WriteString("{ ... }\n\n")
return
}
case "class_declaration":
body := node.ChildByFieldName("body")
if body != nil {
header := strings.TrimRight(string(content[node.StartByte():body.StartByte()]), " \t")
sb.WriteString(header)
sb.WriteString("{\n")
for i := 0; i < int(body.ChildCount()); i++ {
child := body.Child(i)
if child == nil {
continue
}
if child.Type() == "method_definition" {
methBody := child.ChildByFieldName("body")
if methBody != nil {
methSig := strings.TrimRight(string(content[child.StartByte():methBody.StartByte()]), " \t")
sb.WriteString(" ")
sb.WriteString(methSig)
sb.WriteString("{ ... }\n")
continue
}
}
sb.WriteString(" ")
sb.WriteString(string(content[child.StartByte():child.EndByte()]))
sb.WriteString("\n")
}
sb.WriteString("}\n\n")
return
}
case "comment":
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
return
}
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
}
// ---- Python skeleton ----
func skeletonPython(tree *sitter.Tree, content []byte) string {
if tree == nil {
return string(content)
}
root := tree.RootNode()
var sb strings.Builder
skeletonPythonNode(root, content, &sb, "")
return sb.String()
}
func skeletonPythonNode(node *sitter.Node, content []byte, sb *strings.Builder, indent string) {
if node == nil {
return
}
nodeType := node.Type()
switch nodeType {
case "module":
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(i)
if child == nil {
continue
}
skeletonPythonNode(child, content, sb, indent)
}
return
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()])
lines := strings.SplitN(nodeText, "\n", 2)
sb.WriteString(indent)
sb.WriteString(lines[0])
sb.WriteString("\n")
sb.WriteString(indent)
sb.WriteString(" ...\n\n")
return
case "class_definition":
body := node.ChildByFieldName("body")
if body != nil {
header := string(content[node.StartByte():body.StartByte()])
firstLine := strings.SplitN(header, "\n", 2)[0]
sb.WriteString(indent)
sb.WriteString(firstLine)
sb.WriteString("\n")
for i := 0; i < int(body.ChildCount()); i++ {
child := body.Child(i)
if child == nil {
continue
}
if child.Type() == "function_definition" || child.Type() == "decorated_definition" {
childText := string(content[child.StartByte():child.EndByte()])
childLines := strings.SplitN(childText, "\n", 2)
sb.WriteString(indent + " ")
sb.WriteString(childLines[0])
sb.WriteString("\n")
sb.WriteString(indent + " ...")
sb.WriteString("\n")
continue
}
if child.Type() == "expression_statement" {
sb.WriteString(indent + " ")
sb.WriteString(string(content[child.StartByte():child.EndByte()]))
sb.WriteString("\n")
continue
}
}
sb.WriteString("\n")
return
}
case "comment":
sb.WriteString(indent)
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
return
}
sb.WriteString(indent)
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
}
// ---- Rust skeleton ----
func skeletonRust(tree *sitter.Tree, content []byte) string {
if tree == nil {
return string(content)
}
root := tree.RootNode()
var sb strings.Builder
skeletonRustNode(root, content, &sb)
return sb.String()
}
func skeletonRustNode(node *sitter.Node, content []byte, sb *strings.Builder) {
if node == nil {
return
}
nodeType := node.Type()
switch nodeType {
case "source_file":
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(i)
if child == nil {
continue
}
skeletonRustNode(child, content, sb)
}
return
case "function_item":
body := node.ChildByFieldName("body")
if body != nil {
sig := strings.TrimRight(string(content[node.StartByte():body.StartByte()]), " \t")
sb.WriteString(sig)
sb.WriteString("{ ... }\n\n")
return
}
case "impl_item":
body := node.ChildByFieldName("body")
if body != nil {
header := strings.TrimRight(string(content[node.StartByte():body.StartByte()]), " \t")
sb.WriteString(header)
sb.WriteString("{\n")
for i := 0; i < int(body.ChildCount()); i++ {
child := body.Child(i)
if child == nil {
continue
}
if child.Type() == "function_item" {
methBody := child.ChildByFieldName("body")
if methBody != nil {
methSig := strings.TrimRight(string(content[child.StartByte():methBody.StartByte()]), " \t")
sb.WriteString(" ")
sb.WriteString(methSig)
sb.WriteString("{ ... }\n")
continue
}
}
sb.WriteString(" ")
sb.WriteString(string(content[child.StartByte():child.EndByte()]))
sb.WriteString("\n")
}
sb.WriteString("}\n\n")
return
}
case "line_comment", "block_comment":
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
return
}
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
}
+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)
}
}
+435
View File
@@ -0,0 +1,435 @@
package parser
import (
"strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
// StripFlag names the categories of content to remove.
type StripFlag string
const (
StripImports StripFlag = "imports"
StripLicense StripFlag = "license"
StripBlockComments StripFlag = "block_comments"
)
// StripResult holds the stripped content and which flags actually removed content.
type StripResult struct {
Content string
Stripped []StripFlag
}
// StripContent applies requested strip operations to content, in order:
// license → imports → block_comments.
// lang is used to pick language-specific heuristics.
func StripContent(content string, flags []StripFlag, lang protocol.Language) StripResult {
flagSet := make(map[StripFlag]bool, len(flags))
for _, f := range flags {
flagSet[f] = true
}
var stripped []StripFlag
if flagSet[StripLicense] {
next, removed := stripLicense(content)
if removed {
content = next
stripped = append(stripped, StripLicense)
}
}
if flagSet[StripImports] {
next, removed := stripImports(content, lang)
if removed {
content = next
stripped = append(stripped, StripImports)
}
}
if flagSet[StripBlockComments] {
next, removed := stripBlockComments(content, lang)
if removed {
content = next
stripped = append(stripped, StripBlockComments)
}
}
return StripResult{Content: content, Stripped: stripped}
}
// containsLicenseKeyword reports whether lowercased text looks like a license
// 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) {
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
if strings.HasPrefix(trimmed, "/*") {
end := strings.Index(trimmed, "*/")
if end >= 0 {
candidate := trimmed[:end+2]
if containsLicenseKeyword(strings.ToLower(candidate)) {
rest := trimmed[end+2:]
// Consume trailing newline(s)
rest = strings.TrimLeft(rest, "\r\n")
return rest, true
}
}
}
// "//" line-comment header (Go/C/TS). Only contiguous "//" lines belong to
// the header; a non-comment line ends it and is preserved.
if strings.HasPrefix(trimmed, "//") {
lines := strings.Split(trimmed, "\n")
var commentLines, rest []string
for i, l := range lines {
if strings.HasPrefix(strings.TrimSpace(l), "//") {
commentLines = append(commentLines, l)
continue
}
rest = lines[i:]
break
}
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
return strings.Join(rest, "\n"), true
}
}
// 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.
func stripImports(content string, lang protocol.Language) (string, bool) {
switch lang {
case protocol.LangGo:
return stripGoImports(content)
case protocol.LangTypeScript, protocol.LangJavaScript:
return stripTSImports(content)
case protocol.LangPython:
return stripPythonImports(content)
case protocol.LangRust:
return stripRustImports(content)
default:
return content, false
}
}
// stripGoImports removes Go import(...) or single import "..." declarations.
func stripGoImports(content string) (string, bool) {
lines := strings.Split(content, "\n")
var out []string
removed := false
i := 0
for i < len(lines) {
trimLine := strings.TrimSpace(lines[i])
if strings.HasPrefix(trimLine, "import (") || trimLine == "import (" {
// multi-line import block
removed = true
i++ // skip "import ("
for i < len(lines) {
if strings.TrimSpace(lines[i]) == ")" {
i++ // skip closing ")"
break
}
i++
}
// skip one blank line after
if i < len(lines) && strings.TrimSpace(lines[i]) == "" {
i++
}
continue
}
// 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
i++
continue
}
out = append(out, lines[i])
i++
}
if !removed {
return content, false
}
return strings.Join(out, "\n"), true
}
// 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) {
lines := strings.Split(content, "\n")
var out []string
removed := false
inMulti := false
for _, l := range lines {
trimLine := strings.TrimSpace(l)
if inMulti {
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
}
out = append(out, l)
}
if !removed {
return content, false
}
return strings.Join(out, "\n"), true
}
// stripPythonImports removes Python "import ..." and "from ... import ..." lines.
func stripPythonImports(content string) (string, bool) {
lines := strings.Split(content, "\n")
var out []string
removed := false
for _, l := range lines {
trimLine := strings.TrimSpace(l)
if strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "from ") {
removed = true
continue
}
out = append(out, l)
}
if !removed {
return content, false
}
return strings.Join(out, "\n"), true
}
// stripRustImports removes Rust "use ..." declarations.
func stripRustImports(content string) (string, bool) {
lines := strings.Split(content, "\n")
var out []string
removed := false
inMulti := false
for _, l := range lines {
trimLine := strings.TrimSpace(l)
if inMulti {
// look for semicolon terminating multi-line use
if strings.Contains(trimLine, ";") {
inMulti = false
}
removed = true
continue
}
if strings.HasPrefix(trimLine, "use ") {
removed = true
if !strings.HasSuffix(trimLine, ";") {
inMulti = true
}
continue
}
out = append(out, l)
}
if !removed {
return content, false
}
return strings.Join(out, "\n"), true
}
// stripBlockComments removes /* ... */ block comments (Go/TS/C/Rust)
// and Python triple-quoted docstrings.
func stripBlockComments(content string, lang protocol.Language) (string, bool) {
if lang == protocol.LangPython {
return stripPythonDocstrings(content)
}
return stripCStyleBlockComments(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) {
removed := false
out := make([]byte, 0, len(content))
lineHasNonSpace := false
i := 0
for i < len(content) {
if i+1 < len(content) && content[i] == '/' && content[i+1] == '*' {
if end := strings.Index(content[i+2:], "*/"); end >= 0 {
removed = true
standalone := !lineHasNonSpace
i = i + 2 + end + 2 // advance past closing */
if standalone {
out = trimTrailingLineWhitespace(out)
i = skipLineTail(content, i)
lineHasNonSpace = false
}
continue
}
}
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++
}
if !removed {
return content, false
}
return string(out), true
}
// 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) {
removed := false
out := make([]byte, 0, len(content))
lineHasNonSpace := false
i := 0
for i < len(content) {
if i+2 < len(content) {
triple := content[i : i+3]
if triple == `"""` || triple == `'''` {
if end := strings.Index(content[i+3:], triple); end >= 0 {
removed = true
standalone := !lineHasNonSpace
i = i + 3 + end + 3
if standalone {
out = trimTrailingLineWhitespace(out)
i = skipLineTail(content, i)
lineHasNonSpace = false
}
continue
}
}
}
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++
}
if !removed {
return content, false
}
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) }
+643 -24
View File
@@ -1,6 +1,8 @@
package parser
import (
"strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
sitter "github.com/smacker/go-tree-sitter"
)
@@ -25,12 +27,38 @@ func ExtractSymbols(tree *sitter.Tree, content []byte, lang protocol.Language, f
return extractPythonSymbols(root, content, filename)
case protocol.LangC, protocol.LangCpp:
return extractCSymbols(root, content, filename)
case protocol.LangElixir:
return extractElixirSymbols(root, content, filename)
case protocol.LangRust:
return extractRustSymbols(root, content, filename)
default:
return nil
}
}
// 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 {
var symbols []protocol.Symbol
@@ -45,6 +73,10 @@ func extractGoSymbols(root *sitter.Node, content []byte, filename string) []prot
case "type_declaration":
symbol = extractGoType(n, content, filename)
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)
symbols = append(symbols, syms...)
return true
@@ -148,12 +180,20 @@ func extractGoVarConst(n *sitter.Node, content []byte, filename string) []protoc
WalkTree(n, func(node *sitter.Node) bool {
if node.Type() == "const_spec" || node.Type() == "var_spec" {
nameNode := node.ChildByFieldName("name")
if nameNode != nil {
// A spec may declare multiple names (e.g. var a, b = 1, 2);
// 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{
Name: GetNodeText(nameNode, content),
Kind: kind,
Location: NodeLocation(node, filename),
Location: NodeLocation(nameNode, filename),
})
}
}
@@ -178,6 +218,11 @@ func extractJSSymbols(root *sitter.Node, content []byte, filename string) []prot
case "method_definition":
symbol = extractJSMethod(n, content, filename)
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)
symbols = append(symbols, syms...)
return true
@@ -382,29 +427,65 @@ func extractCSymbols(root *sitter.Node, content []byte, filename string) []proto
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 {
declarator := n.ChildByFieldName("declarator")
if declarator == nil {
return nil
}
// Find the function name within the declarator
var name string
WalkTree(declarator, func(node *sitter.Node) bool {
if node.Type() == "identifier" {
name = GetNodeText(node, content)
return false
}
return true
})
name := cDeclaratorName(declarator, content)
if name == "" {
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{
Name: name,
Kind: protocol.SymbolFunction,
Kind: kind,
Location: NodeLocation(n, filename),
}
}
@@ -441,22 +522,22 @@ func extractCFunctionDecl(n *sitter.Node, content []byte, filename string) *prot
return nil
}
var name string
WalkTree(declarator, func(node *sitter.Node) bool {
if node.Type() == "identifier" {
name = GetNodeText(node, content)
return false
}
return true
})
name := cDeclaratorName(declarator, content)
if name == "" {
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{
Name: name,
Kind: protocol.SymbolFunction,
Kind: kind,
Location: NodeLocation(n, filename),
}
}
@@ -472,3 +553,541 @@ func hasFunctionDeclarator(n *sitter.Node) bool {
})
return found
}
// extractElixirSymbols extracts symbols from Elixir code.
// Elixir uses `defmodule` for modules, `def`/`defp` for functions, and `defmacro`/`defmacrop` for macros.
func extractElixirSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
var symbols []protocol.Symbol
WalkTree(root, func(n *sitter.Node) bool {
var symbol *protocol.Symbol
switch n.Type() {
case "call":
symbol = extractElixirCall(n, content, filename)
}
if symbol != nil {
if doc := ExtractDocComment(n, content, protocol.LangElixir); doc != nil {
symbol.Doc = FormatDocComment(doc)
}
symbols = append(symbols, *symbol)
}
return true
})
return symbols
}
// extractElixirCall extracts symbols from Elixir call nodes (def, defp, defmodule, defmacro, etc.).
func extractElixirCall(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// Get the function being called (first child is usually the target)
if n.NamedChildCount() < 1 {
return nil
}
target := n.NamedChild(0)
if target == nil {
return nil
}
targetText := GetNodeText(target, content)
switch targetText {
case "defmodule":
return extractElixirModule(n, content, filename)
case "def", "defp":
return extractElixirFunction(n, content, filename, targetText == "defp")
case "defmacro", "defmacrop":
return extractElixirMacro(n, content, filename)
case "defstruct":
return extractElixirStruct(n, content, filename)
case "defprotocol":
return extractElixirProtocol(n, content, filename)
case "defimpl":
return extractElixirImpl(n, content, filename)
}
return nil
}
// extractElixirModule extracts a module definition.
func extractElixirModule(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defmodule ModuleName do ... end
// The module name is in the arguments
args := n.ChildByFieldName("arguments")
if args == nil {
// Try finding it as the second named child
if n.NamedChildCount() >= 2 {
args = n.NamedChild(1)
}
}
if args == nil {
return nil
}
// Find the alias (module name) in the arguments
var moduleName string
WalkTree(args, func(node *sitter.Node) bool {
if node.Type() == "alias" {
moduleName = GetNodeText(node, content)
return false
}
return true
})
if moduleName == "" {
return nil
}
return &protocol.Symbol{
Name: moduleName,
Kind: protocol.SymbolModule,
Location: NodeLocation(n, filename),
}
}
// extractElixirFunction extracts a function definition.
func extractElixirFunction(n *sitter.Node, content []byte, filename string, isPrivate bool) *protocol.Symbol {
// def function_name(args) do ... end
// The function name and args are in the arguments of the call
if n.NamedChildCount() < 2 {
return nil
}
// Second child contains the function definition
funcDef := n.NamedChild(1)
if funcDef == nil {
return nil
}
var funcName string
// The function definition can be:
// 1. A call node (function with args): func_name(arg1, arg2)
// 2. An identifier (function without args): func_name
switch funcDef.Type() {
case "call":
// Get the function name from the call target
if funcDef.NamedChildCount() >= 1 {
nameNode := funcDef.NamedChild(0)
if nameNode != nil {
funcName = GetNodeText(nameNode, content)
}
}
case "identifier":
funcName = GetNodeText(funcDef, content)
case "binary_operator":
// Guard clause: def func_name(args) when guard do ... end
// The left side contains the actual function call
WalkTree(funcDef, func(node *sitter.Node) bool {
if node.Type() == "call" && node.NamedChildCount() >= 1 {
nameNode := node.NamedChild(0)
if nameNode != nil && nameNode.Type() == "identifier" {
funcName = GetNodeText(nameNode, content)
return false
}
}
if node.Type() == "identifier" && funcName == "" {
funcName = GetNodeText(node, content)
return false
}
return true
})
}
if funcName == "" {
return nil
}
kind := protocol.SymbolFunction
if isPrivate {
funcName = funcName + " (private)"
}
return &protocol.Symbol{
Name: funcName,
Kind: kind,
Location: NodeLocation(n, filename),
}
}
// extractElixirMacro extracts a macro definition.
func extractElixirMacro(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// Similar to function extraction
if n.NamedChildCount() < 2 {
return nil
}
funcDef := n.NamedChild(1)
if funcDef == nil {
return nil
}
var macroName string
switch funcDef.Type() {
case "call":
if funcDef.NamedChildCount() >= 1 {
nameNode := funcDef.NamedChild(0)
if nameNode != nil {
macroName = GetNodeText(nameNode, content)
}
}
case "identifier":
macroName = GetNodeText(funcDef, content)
}
if macroName == "" {
return nil
}
return &protocol.Symbol{
Name: macroName + " (macro)",
Kind: protocol.SymbolFunction,
Location: NodeLocation(n, filename),
}
}
// 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 {
name := "defstruct"
if mod := enclosingElixirModuleName(n, content); mod != "" {
name = "%" + mod + "{}"
}
return &protocol.Symbol{
Name: name,
Kind: protocol.SymbolStruct,
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.
func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defprotocol ProtocolName do ... end
if n.NamedChildCount() < 2 {
return nil
}
args := n.NamedChild(1)
if args == nil {
return nil
}
var protocolName string
WalkTree(args, func(node *sitter.Node) bool {
if node.Type() == "alias" {
protocolName = GetNodeText(node, content)
return false
}
return true
})
if protocolName == "" {
return nil
}
return &protocol.Symbol{
Name: protocolName,
Kind: protocol.SymbolInterface,
Location: NodeLocation(n, filename),
}
}
// extractRustSymbols extracts symbols from Rust code.
func extractRustSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
var symbols []protocol.Symbol
WalkTree(root, func(n *sitter.Node) bool {
var symbol *protocol.Symbol
switch n.Type() {
case "function_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolFunction,
Location: NodeLocation(n, filename),
}
}
case "struct_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolStruct,
Location: NodeLocation(n, filename),
}
}
case "enum_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolEnum,
Location: NodeLocation(n, filename),
}
}
case "trait_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolTrait,
Location: NodeLocation(n, filename),
}
}
case "impl_item":
symbol = extractRustImpl(n, content, filename)
case "type_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolType,
Location: NodeLocation(n, filename),
}
}
case "const_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolConstant,
Location: NodeLocation(n, filename),
}
}
case "static_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolVariable,
Location: NodeLocation(n, filename),
}
}
case "macro_definition":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content) + " (macro)",
Kind: protocol.SymbolFunction,
Location: NodeLocation(n, filename),
}
}
case "mod_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolModule,
Location: NodeLocation(n, filename),
}
}
}
if symbol != nil {
if doc := ExtractDocComment(n, content, protocol.LangRust); doc != nil {
symbol.Doc = FormatDocComment(doc)
}
symbols = append(symbols, *symbol)
}
return true
})
return symbols
}
// extractRustImpl extracts an impl block symbol from Rust code.
func extractRustImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
typeNode := n.ChildByFieldName("type")
traitNode := n.ChildByFieldName("trait")
var name string
if traitNode != nil && typeNode != nil {
name = "impl " + GetNodeText(traitNode, content) + " for " + GetNodeText(typeNode, content)
} else if typeNode != nil {
name = "impl " + GetNodeText(typeNode, content)
} else {
return nil
}
return &protocol.Symbol{
Name: name,
Kind: protocol.SymbolType,
Location: NodeLocation(n, filename),
}
}
// extractElixirImpl extracts a protocol implementation.
func extractElixirImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defimpl Protocol, for: Type do ... end
if n.NamedChildCount() < 2 {
return nil
}
args := n.NamedChild(1)
if args == nil {
return nil
}
var implName string
WalkTree(args, func(node *sitter.Node) bool {
if node.Type() == "alias" {
if implName == "" {
implName = GetNodeText(node, content)
} else {
implName = implName + " for " + GetNodeText(node, content)
return false
}
}
return true
})
if implName == "" {
return nil
}
return &protocol.Symbol{
Name: implName,
Kind: protocol.SymbolClass,
Location: NodeLocation(n, filename),
}
}
// kindMatchesNode returns true if the given SymbolKind matches the node type.
// An empty kind matches all symbol-bearing nodes.
func kindMatchesNode(kind protocol.SymbolKind, nodeType string) bool {
if kind == "" {
return true
}
switch kind {
case protocol.SymbolFunction:
return nodeType == "function_declaration" || nodeType == "function_definition" || nodeType == "function_item"
case protocol.SymbolMethod:
return nodeType == "method_declaration" || nodeType == "method_definition"
case protocol.SymbolClass:
return nodeType == "class_declaration" || nodeType == "class_definition"
case protocol.SymbolStruct:
return nodeType == "struct_item" || nodeType == "type_declaration"
case protocol.SymbolInterface:
return nodeType == "interface_declaration"
case protocol.SymbolType:
return nodeType == "type_declaration" || nodeType == "type_alias_declaration" || nodeType == "type_item"
case protocol.SymbolEnum:
return nodeType == "enum_item"
case protocol.SymbolTrait:
return nodeType == "trait_item"
case protocol.SymbolConstant:
return nodeType == "const_item"
case protocol.SymbolModule:
return nodeType == "mod_item"
}
return true
}
// SymbolCandidate is one symbol matching a name (and optional kind) lookup,
// with its declaration node type and 1-indexed inclusive line range. NameLine
// and NameColumn point at the name identifier itself (1-indexed), which is the
// 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 == "" {
return nil
}
var candidates []SymbolCandidate
WalkTree(tree.RootNode(), func(n *sitter.Node) bool {
nameNode := n.ChildByFieldName("name")
if nameNode == nil {
return true
}
if GetNodeText(nameNode, content) != symbolName {
return true
}
if !symbolDeclNodeTypes[n.Type()] {
return true
}
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 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
}
+294
View File
@@ -7,6 +7,64 @@ import (
"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) {
r := NewRegistry()
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) {
r := NewRegistry()
defer r.Close()
@@ -224,3 +425,96 @@ int main() {
}
}
}
func TestExtractElixirSymbols(t *testing.T) {
r := NewRegistry()
defer r.Close()
content := `defmodule MyApp.User do
@moduledoc """
User module for the application.
"""
defstruct [:name, :email]
@doc """
Creates a new user.
"""
def new(name, email) do
%__MODULE__{name: name, email: email}
end
defp validate(user) do
# Private validation function
user
end
defmacro is_user(term) do
quote do
is_struct(unquote(term), __MODULE__)
end
end
end
defprotocol Greeting do
@doc "Greet the entity"
def greet(entity)
end
defimpl Greeting, for: MyApp.User do
def greet(user) do
"Hello, #{user.name}!"
end
end
`
ctx := context.Background()
result, err := r.Parse(ctx, "test.ex", []byte(content))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangElixir, "test.ex")
// Check that we found some symbols
if len(symbols) == 0 {
t.Fatal("expected to find some symbols")
}
// Look for specific expected symbols - we focus on top-level constructs
// that the current implementation can reliably extract
expectedSymbols := map[string]protocol.SymbolKind{
"MyApp.User": protocol.SymbolModule,
"Greeting": protocol.SymbolInterface,
}
found := make(map[string]bool)
for _, sym := range symbols {
for name, expectedKind := range expectedSymbols {
if sym.Name == name {
found[name] = true
if sym.Kind != expectedKind {
t.Errorf("symbol %s: expected kind %s, got %s", sym.Name, expectedKind, sym.Kind)
}
}
}
}
for name := range expectedSymbols {
if !found[name] {
t.Errorf("expected to find symbol %s, found symbols: %v", name, symbols)
}
}
// Verify we found the defstruct
foundStruct := false
for _, sym := range symbols {
if sym.Kind == protocol.SymbolStruct {
foundStruct = true
break
}
}
if !foundStruct {
t.Error("expected to find a struct symbol")
}
}
+12 -149
View File
@@ -4,70 +4,45 @@ package parser
import (
"context"
"encoding/json"
"fmt"
"gopkg.in/yaml.v3"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
sitter "github.com/smacker/go-tree-sitter"
)
// YAMLNode wraps yaml.Node to provide tree-sitter-like interface
type YAMLNode struct {
*yaml.Node
Content []byte
}
// JSONNode represents a JSON AST node
type JSONNode struct {
Value any
Type string
Children []*JSONNode
Line int
Column int
}
// ParseYAML parses YAML content and returns a tree-sitter-compatible result
// ParseYAML parses YAML content and returns a tree-sitter-compatible result.
// YAML does not use tree-sitter, so the returned ParseResult.Tree is nil.
func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
// Check file size
if len(content) > MaxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
if len(content) > maxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
}
// Parse YAML
var root yaml.Node
if err := yaml.Unmarshal(content, &root); err != nil {
return nil, errors.NewParseError("yaml", filename, err)
}
// Extract syntax errors from YAML parse
syntaxErrors := extractYAMLErrors()
// Create a pseudo tree-sitter tree for compatibility
// We'll use nil for the tree since YAML doesn't use tree-sitter
return &ParseResult{
Tree: nil, // YAML uses yaml.Node instead
Tree: nil, // YAML uses yaml.Node instead of tree-sitter
Language: protocol.LangYAML,
Errors: syntaxErrors,
Errors: extractYAMLErrors(),
Content: content,
}, nil
}
// ParseJSON parses JSON content and returns a tree-sitter-compatible result
// ParseJSON parses JSON content and returns a tree-sitter-compatible result.
// JSON does not use tree-sitter, so the returned ParseResult.Tree is nil.
func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
// Check file size
if len(content) > MaxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
if len(content) > maxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
}
// Parse JSON to validate syntax
var jsonData any
if err := json.Unmarshal(content, &jsonData); err != nil {
return nil, errors.NewParseError("json", filename, err)
}
// JSON parsing succeeded, no syntax errors
return &ParseResult{
Tree: nil, // JSON uses native Go structures
Language: protocol.LangJSON,
@@ -76,120 +51,8 @@ func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byt
}, nil
}
// extractYAMLErrors extracts errors from YAML nodes
// extractYAMLErrors returns YAML syntax errors. yaml.Unmarshal already reports
// parse errors, so reaching here means the document was syntactically valid.
func extractYAMLErrors() []SyntaxError {
// YAML parser already validates during unmarshal
// If we got here, there are no syntax errors
// However, we could add semantic validation here in the future
return []SyntaxError{}
}
// WalkYAML walks a YAML AST and calls fn for each node
func WalkYAML(node *yaml.Node, fn func(*yaml.Node) bool) {
if node == nil || !fn(node) {
return
}
for _, child := range node.Content {
WalkYAML(child, fn)
}
}
// GetYAMLNodeText returns the text representation of a YAML node
func GetYAMLNodeText(node *yaml.Node) string {
if node == nil {
return ""
}
switch node.Kind {
case yaml.DocumentNode:
if len(node.Content) > 0 {
return GetYAMLNodeText(node.Content[0])
}
return ""
case yaml.MappingNode:
return node.Value
case yaml.SequenceNode:
return node.Value
case yaml.ScalarNode:
return node.Value
case yaml.AliasNode:
return node.Value
default:
return ""
}
}
// GetYAMLNodeLocation returns the location of a YAML node
func GetYAMLNodeLocation(node *yaml.Node) protocol.Location {
if node == nil {
return protocol.Location{Line: 1, Column: 1}
}
return protocol.Location{
Line: node.Line,
Column: node.Column,
}
}
// QueryYAML performs a simple query on YAML content
// Example: "$.metadata.name" to find the name field in metadata
func QueryYAML(content []byte, query string) ([]*yaml.Node, error) {
var root yaml.Node
if err := yaml.Unmarshal(content, &root); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
// Simple path-based query implementation
// This is a basic implementation - can be extended with more sophisticated queries
var results []*yaml.Node
WalkYAML(&root, func(node *yaml.Node) bool {
if node.Value == query || node.Tag == query {
results = append(results, node)
}
return true
})
return results, nil
}
// QueryJSON performs a simple query on JSON content
func QueryJSON(content []byte, query string) ([]any, error) {
var data any
if err := json.Unmarshal(content, &data); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
// Basic implementation - can be extended with JSONPath support
var results []any
// For now, just validate that it's valid JSON
results = append(results, data)
return results, nil
}
// ValidateYAML validates YAML content without parsing to full AST
func ValidateYAML(content []byte) error {
var node yaml.Node
if err := yaml.Unmarshal(content, &node); err != nil {
return fmt.Errorf("YAML validation failed: %w", err)
}
return nil
}
// ValidateJSON validates JSON content
func ValidateJSON(content []byte) error {
var data any
if err := json.Unmarshal(content, &data); err != nil {
return fmt.Errorf("JSON validation failed: %w", err)
}
return nil
}
// ToSitterTree is a placeholder that returns nil for YAML/JSON
// These formats don't use tree-sitter, but we keep this for interface compatibility
func (yn *YAMLNode) ToSitterTree() *sitter.Tree {
return nil
}
-81
View File
@@ -4,8 +4,6 @@ import (
"context"
"testing"
"gopkg.in/yaml.v3"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
@@ -202,82 +200,3 @@ version: 1.0.0`)
t.Errorf("expected YAML language for .yml extension, got %s", ymlResult.Language)
}
}
func TestWalkYAML(t *testing.T) {
content := []byte(`metadata:
name: test
labels:
app: myapp
env: prod`)
var root yaml.Node
if err := yaml.Unmarshal(content, &root); err != nil {
t.Fatalf("failed to parse YAML: %v", err)
}
nodeCount := 0
WalkYAML(&root, func(node *yaml.Node) bool {
nodeCount++
return true
})
if nodeCount == 0 {
t.Error("expected to visit nodes, but count is 0")
}
}
func TestValidateYAML(t *testing.T) {
tests := []struct {
name string
content []byte
shouldError bool
}{
{
name: "valid YAML",
content: []byte("name: test\nvalue: 100"),
shouldError: false,
},
{
name: "invalid YAML",
content: []byte("name: test\n bad:\n[unclosed"),
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateYAML(tt.content)
if (err != nil) != tt.shouldError {
t.Errorf("ValidateYAML() error = %v, shouldError = %v", err, tt.shouldError)
}
})
}
}
func TestValidateJSON(t *testing.T) {
tests := []struct {
name string
content []byte
shouldError bool
}{
{
name: "valid JSON",
content: []byte(`{"name": "test", "value": 100}`),
shouldError: false,
},
{
name: "invalid JSON",
content: []byte(`{"name": "test", "value": 100`),
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateJSON(tt.content)
if (err != nil) != tt.shouldError {
t.Errorf("ValidateJSON() error = %v, shouldError = %v", err, tt.shouldError)
}
})
}
}
+216
View File
@@ -0,0 +1,216 @@
package query
import (
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
// makeResults builds N dummy MatchResults.
func makeResults(n int) []MatchResult {
out := make([]MatchResult, n)
for i := range out {
out[i] = MatchResult{
File: "file.go",
Location: protocol.Location{
Line: i + 1,
Column: 1,
},
Text: "func Foo() {}\nline2",
}
}
return out
}
// TestFormatResultsVerboseDefault verifies verbose format includes code blocks (no preamble by default).
func TestFormatResultsVerboseDefault(t *testing.T) {
results := makeResults(2)
out := FormatResultsWithOptions(results, 0, "verbose", 0)
// v2 default: no preamble
if strings.Contains(out, "Found ") {
t.Errorf("v2 default should NOT emit preamble, got:\n%s", out)
}
if !strings.Contains(out, "```") {
t.Error("verbose mode should include code blocks")
}
}
// TestFormatResultsVerbosePreamble verifies verbose=true restores the preamble.
func TestFormatResultsVerbosePreamble(t *testing.T) {
results := makeResults(2)
out := FormatResultsWithOptions(results, 0, "verbose", 0, true)
if !strings.Contains(out, "Found 2 match(es):") {
t.Errorf("expected preamble with verbose=true, got:\n%s", out)
}
}
func TestFormatResultsCompact(t *testing.T) {
results := makeResults(3)
out := FormatResultsWithOptions(results, 0, "compact", 0)
// v2 default: no preamble in compact mode
// Should NOT have code blocks
if strings.Contains(out, "```") {
t.Error("compact mode should not have code blocks")
}
// Should have one line per match (beyond header)
lines := strings.Split(strings.TrimSpace(out), "\n")
// First two lines are "Found..." and blank, then 3 match lines
matchLines := 0
for _, l := range lines {
if strings.Contains(l, "file.go:") {
matchLines++
}
}
if matchLines != 3 {
t.Errorf("expected 3 match lines in compact mode, got %d\nOutput:\n%s", matchLines, out)
}
}
func TestFormatResultsLocation(t *testing.T) {
results := makeResults(3)
out := FormatResultsWithOptions(results, 0, "location", 0)
if strings.Contains(out, "```") {
t.Error("location mode should not have code blocks")
}
// Should be file:line only
for i := 1; i <= 3; i++ {
expected := "file.go:" + itoa(i)
if !strings.Contains(out, expected) {
t.Errorf("location output missing %s", expected)
}
}
}
func TestFormatResultsMaxResults(t *testing.T) {
results := makeResults(5)
out := FormatResultsWithOptions(results, 3, "verbose", 0)
// v2 default: no preamble — check that exactly 3 code blocks are present
codeBlockCount := strings.Count(out, "```")
if codeBlockCount != 6 { // 3 opening + 3 closing = 6
t.Errorf("expected 3 matches (6 backtick markers), got %d in:\n%s", codeBlockCount, out)
}
if !strings.Contains(out, "[remaining: 2]") {
t.Errorf("expected [remaining: 2] footer, got:\n%s", out)
}
}
func TestFormatResultsOffset(t *testing.T) {
results := makeResults(5)
// Skip first 2, show all remaining
out := FormatResultsWithOptions(results, 0, "verbose", 2)
// offset=2 from 5 results → 3 results; check 3 code blocks
codeBlockCount := strings.Count(out, "```")
if codeBlockCount != 6 {
t.Errorf("expected 3 matches (6 backtick markers) after offset=2, got %d in:\n%s", codeBlockCount, out)
}
}
func TestFormatResultsOffsetBeyondEnd(t *testing.T) {
results := makeResults(3)
out := FormatResultsWithOptions(results, 0, "verbose", 10)
if out != "No matches found." {
t.Errorf("expected 'No matches found.' for offset beyond end, got: %s", out)
}
}
func TestFormatResultsPaginationCursor(t *testing.T) {
// Offset=2, maxResults=2, 5 total → show items 3&4, remaining=1
results := makeResults(5)
out := FormatResultsWithOptions(results, 2, "verbose", 2)
// offset=2, maxResults=2 → items 3&4; check 2 code blocks
codeBlockCount := strings.Count(out, "```")
if codeBlockCount != 4 {
t.Errorf("expected 2 matches (4 backtick markers), got %d in:\n%s", codeBlockCount, out)
}
if !strings.Contains(out, "[remaining: 1]") {
t.Errorf("expected [remaining: 1], got:\n%s", out)
}
}
// 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) {
out := FormatResultsWithOptions(nil, 0, "verbose", 0)
if out != "No matches found." {
t.Errorf("expected 'No matches found.', got: %s", out)
}
}
func TestFormatResultsBackwardCompat(t *testing.T) {
// FormatResults wrapper should produce same output as FormatResultsWithOptions with verbose=false (default).
results := makeResults(2)
a := FormatResults(results, 0)
b := FormatResultsWithOptions(results, 0, "verbose", 0)
if a != b {
t.Error("FormatResults and FormatResultsWithOptions(verbose,0) should be identical")
}
// Both should have no preamble.
if strings.Contains(a, "Found ") {
t.Error("FormatResults should not emit preamble by default")
}
}
func TestFirstLineOf(t *testing.T) {
cases := []struct {
input string
maxLen int
want string
}{
{"hello world", 20, "hello world"},
{"line1\nline2", 20, "line1"},
{"\n\nfoo", 20, "foo"},
{"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 {
got := firstLineOf(c.input, c.maxLen)
if got != c.want {
t.Errorf("firstLineOf(%q, %d) = %q, want %q", c.input, c.maxLen, got, c.want)
}
}
}
func itoa(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return strings.TrimRight(strings.TrimRight(
func() string {
buf := make([]byte, 20)
pos := 20
for n > 0 {
pos--
buf[pos] = byte('0' + n%10)
n /= 10
}
return string(buf[pos:])
}(), ""), "")
}
+127 -74
View File
@@ -6,37 +6,13 @@ import (
"fmt"
"regexp"
"strings"
"sync"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
sitter "github.com/smacker/go-tree-sitter"
)
// Global regex cache for compiled patterns (thread-safe)
var regexCache sync.Map // string -> *regexp.Regexp
// compileRegex compiles a regex pattern with caching for performance.
// Cached patterns avoid repeated compilation overhead (10-50x speedup).
// Thread-safe: uses LoadOrStore to prevent race conditions.
func compileRegex(pattern string) (*regexp.Regexp, error) {
// Check cache first
if cached, ok := regexCache.Load(pattern); ok {
return cached.(*regexp.Regexp), nil
}
// Compile regex
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
// Try to store - if another goroutine already stored it, use theirs
// This prevents race conditions where multiple goroutines compile the same pattern
actual, _ := regexCache.LoadOrStore(pattern, re)
return actual.(*regexp.Regexp), nil
}
// ASTQuery defines a query for matching AST patterns.
type ASTQuery struct {
Pattern string `json:"pattern"` // code pattern with $VAR placeholders
@@ -46,13 +22,11 @@ type ASTQuery struct {
// QueryFilters provide additional filtering criteria.
type QueryFilters struct {
HasChild *ASTQuery `json:"has_child,omitempty"`
HasParent *ASTQuery `json:"has_parent,omitempty"`
NameMatches string `json:"name_matches,omitempty"`
NameExact string `json:"name_exact,omitempty"`
InFile string `json:"in_file,omitempty"`
NotInFile string `json:"not_in_file,omitempty"`
KindIn []string `json:"kind_in,omitempty"`
NameMatches string `json:"name_matches,omitempty"`
NameExact string `json:"name_exact,omitempty"`
InFile string `json:"in_file,omitempty"`
NotInFile string `json:"not_in_file,omitempty"`
KindIn []string `json:"kind_in,omitempty"`
}
// MatchResult represents a single match from a query.
@@ -283,7 +257,7 @@ func matchPatternHeuristic(node *sitter.Node, pattern *ParsedPattern, content []
}
// Match struct patterns (Go, C, C++)
if strings.Contains(patternLower, "struct ") || strings.Contains(patternLower, "type ") && strings.Contains(patternLower, "struct") {
if (strings.Contains(patternLower, "struct ") || strings.Contains(patternLower, "type ")) && strings.Contains(patternLower, "struct") {
if nodeType != "type_declaration" && nodeType != "struct_specifier" {
return false
}
@@ -438,7 +412,7 @@ func passesFilters(node *sitter.Node, filters QueryFilters, content []byte) bool
return false
}
name := parser.GetNodeText(nameNode, content)
re, err := compileRegex(filters.NameMatches)
re, err := util.CompileRegex(filters.NameMatches)
if err != nil {
return false
}
@@ -477,62 +451,141 @@ func passesFilters(node *sitter.Node, filters QueryFilters, content []byte) bool
return true
}
// FormatResults formats match results for display.
// FormatResults formats match results for display (backward-compat wrapper, verbose mode).
func FormatResults(results []MatchResult, maxResults int) string {
return FormatResultsWithOptions(results, maxResults, "verbose", 0)
}
// FormatResultsWithOptions formats match results with configurable output format.
// format: "verbose" (default) | "compact" | "location"
// 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).
// 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 {
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 {
return "No matches found."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Found %d match(es):\n\n", len(results)))
displayCount := len(results)
truncated := false
if maxResults > 0 && displayCount > maxResults {
displayCount = maxResults
truncated = true
// Apply offset (pagination skip).
if offset > 0 {
if offset >= len(results) {
return "No matches found."
}
results = results[offset:]
}
for i := 0; i < displayCount; i++ {
r := results[i]
nodeType := "unknown"
if r.Node != nil {
nodeType = r.Node.Type()
}
sb.WriteString(fmt.Sprintf("**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType))
// Determine how many to render and whether more remain.
renderCount := len(results)
remaining := 0
if maxResults > 0 && renderCount > maxResults {
remaining = renderCount - maxResults
renderCount = maxResults
}
// Truncate very long text
text := r.Text
if len(text) > 500 {
text = text[:500] + "..."
}
sb.WriteString("```\n")
sb.WriteString(text)
sb.WriteString("\n```\n")
var sb strings.Builder
// Show captures
if len(r.Captures) > 0 {
sb.WriteString("Captures: ")
first := true
for name, cap := range r.Captures {
if !first {
sb.WriteString(", ")
// Emit preamble only when verbose=true is explicitly passed (opt-in, default off).
if verbose {
fmt.Fprintf(&sb, "Found %d match(es):\n", renderCount)
}
switch format {
case "compact":
for i := 0; i < renderCount; i++ {
r := results[i]
nodeType := "unknown"
if r.Node != nil {
nodeType = r.Node.Type()
}
firstLine := firstLineOf(r.Text, 80)
fmt.Fprintf(&sb, "%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)
}
case "location":
for i := 0; i < renderCount; i++ {
r := results[i]
fmt.Fprintf(&sb, "%s:%d\n", r.File, r.Location.Line)
}
default: // "verbose"
for i := 0; i < renderCount; i++ {
r := results[i]
nodeType := "unknown"
if r.Node != nil {
nodeType = r.Node.Type()
}
fmt.Fprintf(&sb, "**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType)
// Truncate very long text
text := r.Text
if len(text) > 500 {
text = text[:500] + "..."
}
sb.WriteString("```\n")
sb.WriteString(text)
sb.WriteString("\n```\n")
// Show captures
if len(r.Captures) > 0 {
sb.WriteString("Captures: ")
first := true
for name, cap := range r.Captures {
if !first {
sb.WriteString(", ")
}
first = false
capText := cap.Text
if len(capText) > 50 {
capText = capText[:50] + "..."
}
fmt.Fprintf(&sb, "$%s=%s", name, capText)
}
first = false
capText := cap.Text
if len(capText) > 50 {
capText = capText[:50] + "..."
}
sb.WriteString(fmt.Sprintf("$%s=%s", name, capText))
sb.WriteString("\n")
}
sb.WriteString("\n")
}
sb.WriteString("\n")
}
if truncated {
sb.WriteString(fmt.Sprintf("... and %d more matches (truncated)\n", len(results)-maxResults))
if remaining > 0 {
if cursorLine != "" {
// Caller supplied a fully-formed "[cursor: ..., remaining: N]" footer.
sb.WriteString(strings.TrimRight(cursorLine, "\n"))
sb.WriteByte('\n')
} else {
fmt.Fprintf(&sb, "[remaining: %d]\n", remaining)
}
}
return sb.String()
}
// firstLineOf returns the first non-empty line of s, trimmed and capped at maxLen chars.
func firstLineOf(s string, maxLen int) string {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if len(line) > maxLen {
return line[:util.RuneBoundary(line, maxLen-1)] + "…"
}
return line
}
return ""
}
+149
View File
@@ -499,6 +499,155 @@ func TestFormatResults(t *testing.T) {
}
}
// TestMatchStructOperatorPrecedence verifies the C-07 operator precedence fix.
// Before the fix, patterns like "struct Foo" would match because
// strings.Contains(p, "struct ") short-circuited the entire condition.
// After the fix, both "struct" must be present for the struct branch to match.
func TestMatchStructOperatorPrecedence(t *testing.T) {
reg := parser.NewRegistry()
defer reg.Close()
matcher := NewMatcher(reg)
content := `package main
type Server struct {
Port int
}
func main() {}
`
ctx := context.Background()
result, err := reg.Parse(ctx, "test.go", []byte(content))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
tests := []struct {
name string
pattern string
wantMatches int
}{
{
name: "type struct pattern should match",
pattern: "type $NAME struct { $$$FIELDS }",
wantMatches: 1, // Server
},
{
name: "struct keyword alone should match",
pattern: "struct $NAME { $$$FIELDS }",
wantMatches: 1, // Server
},
{
name: "func pattern should not match struct branch",
pattern: "func $NAME() {}",
wantMatches: 1, // main (matches function branch, not struct)
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query := &ASTQuery{
Pattern: tt.pattern,
Language: "go",
}
results, err := matcher.Match(ctx, query, result.Tree, []byte(content), "test.go")
if err != nil {
t.Fatalf("match failed: %v", err)
}
if len(results) != tt.wantMatches {
t.Errorf("expected %d matches, got %d", tt.wantMatches, len(results))
for i, r := range results {
t.Logf("match %d: type=%s, text=%q", i, r.Node.Type(), truncateForLog(r.Text, 80))
}
}
})
}
}
func truncateForLog(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
// TestPassesFilters_AllBranches tests passesFilters for each filter type.
func TestPassesFilters_AllBranches(t *testing.T) {
reg := parser.NewRegistry()
defer reg.Close()
content := `package main
func Alpha() {}
func Beta() {}
func Gamma() {}
`
ctx := context.Background()
result, err := reg.Parse(ctx, "test.go", []byte(content))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
matcher := NewMatcher(reg)
tests := []struct {
name string
filters QueryFilters
wantMatches int
}{
{
name: "no filters matches all",
filters: QueryFilters{},
wantMatches: 3,
},
{
name: "name_exact filter",
filters: QueryFilters{NameExact: "Alpha"},
wantMatches: 1,
},
{
name: "name_matches regex filter",
filters: QueryFilters{NameMatches: "^[AB]"},
wantMatches: 2,
},
{
name: "kind_in filter",
filters: QueryFilters{KindIn: []string{"function_declaration"}},
wantMatches: 3,
},
{
name: "kind_in filter excludes non-matching kinds",
filters: QueryFilters{KindIn: []string{"class_declaration"}},
wantMatches: 0,
},
{
name: "combined name_exact and kind_in",
filters: QueryFilters{NameExact: "Beta", KindIn: []string{"function_declaration"}},
wantMatches: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query := &ASTQuery{
Pattern: "func $NAME() {}",
Language: "go",
Filters: tt.filters,
}
results, err := matcher.Match(ctx, query, result.Tree, []byte(content), "test.go")
if err != nil {
t.Fatalf("match failed: %v", err)
}
if len(results) != tt.wantMatches {
t.Errorf("expected %d matches, got %d", tt.wantMatches, len(results))
}
})
}
}
func TestQueryValidation(t *testing.T) {
reg := parser.NewRegistry()
defer reg.Close()
+36 -41
View File
@@ -4,23 +4,25 @@ import (
"regexp"
"sync"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
)
// TestCompileRegexCaching tests that regex compilation is cached.
func TestCompileRegexCaching(t *testing.T) {
// Clear cache before test
regexCache = sync.Map{}
util.ClearRegexCache()
pattern := `^test_\w+$`
// First compilation
re1, err := compileRegex(pattern)
re1, err := util.CompileRegex(pattern)
if err != nil {
t.Fatalf("First compile failed: %v", err)
}
// Second compilation should return cached version
re2, err := compileRegex(pattern)
re2, err := util.CompileRegex(pattern)
if err != nil {
t.Fatalf("Second compile failed: %v", err)
}
@@ -29,22 +31,12 @@ func TestCompileRegexCaching(t *testing.T) {
if re1 != re2 {
t.Error("Expected cached regex to be reused, got different objects")
}
// Verify it's in the cache
cached, ok := regexCache.Load(pattern)
if !ok {
t.Error("Pattern not found in cache")
}
if cached.(*regexp.Regexp) != re1 {
t.Error("Cached regex doesn't match returned regex")
}
}
// TestCompileRegexConcurrent tests concurrent regex compilation.
func TestCompileRegexConcurrent(t *testing.T) {
// Clear cache before test
regexCache = sync.Map{}
util.ClearRegexCache()
pattern := `[a-z]+_\d+`
const numGoroutines = 100
@@ -60,7 +52,7 @@ func TestCompileRegexConcurrent(t *testing.T) {
go func() {
defer wg.Done()
re, err := compileRegex(pattern)
re, err := util.CompileRegex(pattern)
if err != nil {
errors <- err
return
@@ -89,26 +81,30 @@ func TestCompileRegexConcurrent(t *testing.T) {
// TestCompileRegexInvalidPattern tests error handling for invalid patterns.
func TestCompileRegexInvalidPattern(t *testing.T) {
// Clear cache before test
regexCache = sync.Map{}
util.ClearRegexCache()
invalidPattern := `[invalid(`
_, err := compileRegex(invalidPattern)
_, err := util.CompileRegex(invalidPattern)
if err == nil {
t.Error("Expected error for invalid pattern, got nil")
}
// Invalid patterns should not be cached
_, ok := regexCache.Load(invalidPattern)
if ok {
t.Error("Invalid pattern should not be cached")
// Verify that a valid pattern still works after an invalid one
validPattern := `^valid$`
re, err := util.CompileRegex(validPattern)
if err != nil {
t.Errorf("Expected valid pattern to compile, got error: %v", err)
}
if re == nil {
t.Error("Expected non-nil regex for valid pattern")
}
}
// TestCompileRegexMultiplePatterns tests that different patterns are cached separately.
func TestCompileRegexMultiplePatterns(t *testing.T) {
// Clear cache before test
regexCache = sync.Map{}
util.ClearRegexCache()
patterns := []string{
`^test_\w+$`,
@@ -121,26 +117,14 @@ func TestCompileRegexMultiplePatterns(t *testing.T) {
// Compile all patterns
for i, pattern := range patterns {
re, err := compileRegex(pattern)
re, err := util.CompileRegex(pattern)
if err != nil {
t.Fatalf("Compile failed for pattern %s: %v", pattern, err)
}
compiled[i] = re
}
// Verify all are cached
for i, pattern := range patterns {
cached, ok := regexCache.Load(pattern)
if !ok {
t.Errorf("Pattern %s not in cache", pattern)
}
if cached.(*regexp.Regexp) != compiled[i] {
t.Errorf("Cached regex for %s doesn't match compiled version", pattern)
}
}
// All should be different objects
// All should be different objects (different patterns)
for i := 0; i < len(compiled); i++ {
for j := i + 1; j < len(compiled); j++ {
if compiled[i] == compiled[j] {
@@ -148,6 +132,17 @@ func TestCompileRegexMultiplePatterns(t *testing.T) {
}
}
}
// Re-compile should return cached versions
for i, pattern := range patterns {
re, err := util.CompileRegex(pattern)
if err != nil {
t.Fatalf("Re-compile failed for pattern %s: %v", pattern, err)
}
if re != compiled[i] {
t.Errorf("Pattern %s was not cached properly", pattern)
}
}
}
// BenchmarkCompileRegex_Uncached benchmarks regex compilation without caching.
@@ -163,23 +158,23 @@ func BenchmarkCompileRegex_Uncached(b *testing.B) {
// BenchmarkCompileRegex_Cached benchmarks regex compilation with caching.
func BenchmarkCompileRegex_Cached(b *testing.B) {
// Clear cache
regexCache = sync.Map{}
util.ClearRegexCache()
pattern := `^\w+_[0-9]{3,5}_[a-zA-Z]+$`
// Pre-populate cache
_, _ = compileRegex(pattern)
_, _ = util.CompileRegex(pattern)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = compileRegex(pattern)
_, _ = util.CompileRegex(pattern)
}
}
// BenchmarkCompileRegex_MixedPatterns benchmarks realistic workload with multiple patterns.
func BenchmarkCompileRegex_MixedPatterns(b *testing.B) {
// Clear cache
regexCache = sync.Map{}
util.ClearRegexCache()
patterns := []string{
`^test_\w+$`,
@@ -193,6 +188,6 @@ func BenchmarkCompileRegex_MixedPatterns(b *testing.B) {
for i := 0; i < b.N; i++ {
// Simulate realistic access pattern
pattern := patterns[i%len(patterns)]
_, _ = compileRegex(pattern)
_, _ = util.CompileRegex(pattern)
}
}
+131
View File
@@ -0,0 +1,131 @@
package search
import (
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"log/slog"
"os"
)
func newTestSearcher(t *testing.T) *Searcher {
t.Helper()
cfg := &config.Config{
WorkspaceRoot: t.TempDir(),
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
// Create a Searcher directly without requiring rg
return &Searcher{cfg: cfg, logger: logger, rgPath: "rg"}
}
func makeSearchResults(lines []int) *SearchResults {
results := make([]Result, len(lines))
for i, l := range lines {
results[i] = Result{
File: "/workspace/foo.go",
Line: l,
MatchText: "match at line " + itoa2(l),
}
}
return &SearchResults{Results: results}
}
// itoa2 simple int to string for test
func itoa2(n int) string {
if n == 0 {
return "0"
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}
// TestFormatResultsVerboseBackwardCompat verifies that Verbose=true restores the v1 preamble.
func TestFormatResultsVerboseBackwardCompat(t *testing.T) {
s := newTestSearcher(t)
sr := makeSearchResults([]int{1, 5, 10})
// With Verbose=true, the preamble is emitted (v1 behaviour).
out := s.FormatResultsWithOptions(sr, FormatOptions{Verbose: true})
if !strings.Contains(out, "Found 3 matches in 1 files") {
t.Errorf("expected header with Verbose=true, got:\n%s", out)
}
if !strings.Contains(out, "L1│") {
t.Errorf("expected L1 match line, got:\n%s", out)
}
}
// TestFormatResultsDefaultNoPreamble verifies the v2 default has no preamble.
func TestFormatResultsDefaultNoPreamble(t *testing.T) {
s := newTestSearcher(t)
sr := makeSearchResults([]int{1, 5, 10})
out := s.FormatResults(sr)
if strings.Contains(out, "Found ") {
t.Errorf("v2 default should NOT emit preamble, got:\n%s", out)
}
if !strings.Contains(out, "L1│") {
t.Errorf("expected L1 match line, got:\n%s", out)
}
}
func TestFormatResultsClusterSingleLine(t *testing.T) {
s := newTestSearcher(t)
// Non-consecutive lines → each should appear separately
sr := makeSearchResults([]int{1, 5, 10})
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
if !strings.Contains(out, "L1│") {
t.Errorf("expected L1 cluster, got:\n%s", out)
}
if !strings.Contains(out, "L5│") {
t.Errorf("expected L5 cluster, got:\n%s", out)
}
// Should NOT have " │" context lines in cluster mode
if strings.Contains(out, " │") {
t.Errorf("cluster mode should not have context-line decoration, got:\n%s", out)
}
}
func TestFormatResultsClusterConsecutive(t *testing.T) {
s := newTestSearcher(t)
// Lines 3,4,5 are consecutive → should be clustered as L3-5
sr := makeSearchResults([]int{3, 4, 5, 10})
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
if !strings.Contains(out, "L3-5│") {
t.Errorf("expected L3-5 cluster, got:\n%s", out)
}
if !strings.Contains(out, "L10│") {
t.Errorf("expected L10 separate cluster, got:\n%s", out)
}
}
func TestFormatResultsClusterAdjacentMerge(t *testing.T) {
s := newTestSearcher(t)
// Lines 7 and 8 differ by 1 → merge
sr := makeSearchResults([]int{7, 8})
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
if !strings.Contains(out, "L7-8│") {
t.Errorf("expected L7-8 cluster, got:\n%s", out)
}
}
func TestFormatResultsCursorLine(t *testing.T) {
s := newTestSearcher(t)
sr := makeSearchResults([]int{1})
cursorText := "[cursor: abc123, remaining: 5]"
out := s.FormatResultsWithOptions(sr, FormatOptions{CursorLine: cursorText})
if !strings.Contains(out, cursorText) {
t.Errorf("expected cursor footer in output, got:\n%s", out)
}
}
func TestFormatResultsNoMatchesVerbose(t *testing.T) {
s := newTestSearcher(t)
sr := &SearchResults{Results: nil}
out := s.FormatResults(sr)
if out != "No matches found." {
t.Errorf("expected 'No matches found.', got: %s", out)
}
}
+164 -47
View File
@@ -13,6 +13,7 @@ import (
json "github.com/goccy/go-json"
"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/protocol"
)
@@ -131,6 +132,11 @@ func (s *Searcher) Search(ctx context.Context, req *Request) (*SearchResults, er
WithRemediation("Provide a non-empty search pattern")
}
// Validate that at least one provided path is allowed
if err := s.validatePaths(req.Paths); err != nil {
return nil, err
}
// Build ripgrep command
args := s.buildArgs(req)
@@ -214,10 +220,9 @@ func (s *Searcher) buildArgs(req *Request) []string {
args = append(args, "--no-ignore")
}
// Max count per file to limit results
if req.MaxResults > 0 {
args = append(args, fmt.Sprintf("--max-count=%d", req.MaxResults))
}
// Result cap enforced in-process by parseOutput. rg has no cross-file
// total-count flag in stable releases, so we don't pass one; --max-count is
// per-file and would miss results unevenly.
// Add pattern
args = append(args, "--", req.Pattern)
@@ -237,15 +242,42 @@ func (s *Searcher) buildArgs(req *Request) []string {
return args
}
// validatePaths checks that at least one caller-provided path is allowed.
// Returns an error if paths were provided but none passed IsPathAllowed.
func (s *Searcher) validatePaths(paths []string) error {
if len(paths) == 0 {
return nil // no explicit paths — will default to workspace root
}
for _, p := range paths {
if s.cfg.IsPathAllowed(p) {
return nil
}
}
return errors.New(errors.ErrPathNotAllowed, "all provided search paths are outside the workspace root").
WithContext("paths", fmt.Sprintf("%v", paths)).
WithRemediation("Provide paths within the workspace root")
}
// resultPathAllowed reports whether a ripgrep result path is within the
// workspace root. ripgrep runs with Dir=WorkspaceRoot and emits paths relative
// to it, so a relative path is joined to the root before the check; IsPathAllowed
// then resolves symlinks, rejecting any path that --follow led outside the root.
func (s *Searcher) resultPathAllowed(p string) bool {
if !filepath.IsAbs(p) {
p = filepath.Join(s.cfg.WorkspaceRoot, p)
}
return s.cfg.IsPathAllowed(p)
}
// parseOutput parses ripgrep JSON output.
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
results := &SearchResults{
Results: []Result{},
}
// Track context by file and line
contextBefore := make(map[string][]string) // file -> lines before current match
currentFile := ""
// Track before-context lines linearly: accumulate context lines until the next match consumes them.
var pendingBefore []string
pendingFile := ""
scanner := bufio.NewScanner(output)
for scanner.Scan() {
@@ -267,6 +299,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
continue
}
// Defense in depth: never surface a file outside the workspace root.
// ripgrep with --follow (default on) can traverse a symlink whose
// target lives outside the root; dropping those matches keeps search
// from leaking external file content.
if !s.resultPathAllowed(match.Path.Text) {
continue
}
// Check max results
if maxResults > 0 && len(results.Results) >= maxResults {
results.Truncated = true
@@ -285,14 +325,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
result.Column = match.Submatches[0].Start + 1 // 1-indexed
}
// Add context before
if ctx, ok := contextBefore[match.Path.Text]; ok {
result.Context.Before = ctx
delete(contextBefore, match.Path.Text)
// Attach pending before-context if it belongs to this file
if pendingFile == match.Path.Text && len(pendingBefore) > 0 {
result.Context.Before = pendingBefore
}
pendingBefore = nil
pendingFile = ""
results.Results = append(results.Results, result)
currentFile = match.Path.Text
case "context":
var ctx rgContext
@@ -302,19 +342,20 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
lineText := strings.TrimRight(ctx.Lines.Text, "\n\r")
// Determine if this is before or after context
isAfter := false
if len(results.Results) > 0 {
lastResult := &results.Results[len(results.Results)-1]
if lastResult.File == ctx.Path.Text && ctx.LineNumber > lastResult.Line {
// This is after context
lastResult.Context.After = append(lastResult.Context.After, lineText)
} else if ctx.Path.Text == currentFile || currentFile == "" {
// This is before context for a potential upcoming match
contextBefore[ctx.Path.Text] = append(contextBefore[ctx.Path.Text], lineText)
last := &results.Results[len(results.Results)-1]
if last.File == ctx.Path.Text && ctx.LineNumber > last.Line {
last.Context.After = append(last.Context.After, lineText)
isAfter = true
}
} else {
// Before any match - store as potential before context
contextBefore[ctx.Path.Text] = append(contextBefore[ctx.Path.Text], lineText)
}
if !isAfter {
if pendingFile != ctx.Path.Text {
pendingBefore = nil
pendingFile = ctx.Path.Text
}
pendingBefore = append(pendingBefore, lineText)
}
case "summary":
@@ -333,8 +374,21 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
return results, nil
}
// FormatResults formats search results for display.
// FormatOptions controls how search results are rendered.
type FormatOptions struct {
Cluster bool // coalesce consecutive matches into line-range blocks
CursorLine string // if non-empty, appended as a footer line
Verbose bool // if true, emit "Found N matches in M files:" preamble (opt-in)
}
// FormatResults formats search results for display (backward-compat wrapper).
func (s *Searcher) FormatResults(results *SearchResults) string {
return s.FormatResultsWithOptions(results, FormatOptions{})
}
// FormatResultsWithOptions formats search results with configurable output.
// By default the "Found N matches in M files:" preamble is omitted; set opts.Verbose=true to restore it.
func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatOptions) string {
if len(results.Results) == 0 {
return "No matches found."
}
@@ -351,14 +405,20 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
fileResults[r.File] = append(fileResults[r.File], r)
}
// Write summary
totalMatches := len(results.Results)
fileCount := len(fileResults)
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
if results.Truncated {
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
// Write preamble only when Verbose is requested.
if opts.Verbose {
totalMatches := len(results.Results)
fileCount := len(fileResults)
fmt.Fprintf(&sb, "Found %d matches in %d files", totalMatches, fileCount)
if results.Truncated {
fmt.Fprintf(&sb, " (truncated, total: %d)", results.Total)
}
sb.WriteString(":\n\n")
} else if results.Truncated && opts.CursorLine == "" {
// 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)
}
sb.WriteString(":\n\n")
// Write results grouped by file
for _, file := range fileOrder {
@@ -370,32 +430,89 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
}
}
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
fmt.Fprintf(&sb, "**%s**\n", relPath)
for _, r := range fileResults[file] {
// Write context before
for _, ctx := range r.Context.Before {
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
}
// Write match line
sb.WriteString(fmt.Sprintf("L%d│ %s\n", r.Line, truncateLine(r.MatchText, 200)))
// Write context after
for _, ctx := range r.Context.After {
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
}
if opts.Cluster {
writeClusteredResults(&sb, fileResults[file])
} else {
writeVerboseResults(&sb, fileResults[file])
}
sb.WriteString("\n")
}
if opts.CursorLine != "" {
sb.WriteString(opts.CursorLine)
sb.WriteString("\n")
}
return sb.String()
}
// truncateLine truncates a line if it exceeds maxLen.
// writeVerboseResults writes results in the standard verbose format.
func writeVerboseResults(sb *strings.Builder, results []Result) {
for _, r := range results {
// Write context before
for _, ctx := range r.Context.Before {
fmt.Fprintf(sb, " │ %s\n", truncateLine(ctx, 200))
}
// Write match line
fmt.Fprintf(sb, "L%d│ %s\n", r.Line, truncateLine(r.MatchText, 200))
// Write context after
for _, ctx := range r.Context.After {
fmt.Fprintf(sb, " │ %s\n", truncateLine(ctx, 200))
}
}
}
// writeClusteredResults coalesces consecutive or adjacent match lines into
// a single "L12-14│ <first-match-text>" entry. Context lines are dropped
// in cluster mode to maximise information density.
func writeClusteredResults(sb *strings.Builder, results []Result) {
if len(results) == 0 {
return
}
type clusterEntry struct {
startLine int
endLine int
firstText string
}
var clusters []clusterEntry
cur := clusterEntry{
startLine: results[0].Line,
endLine: results[0].Line,
firstText: results[0].MatchText,
}
for _, r := range results[1:] {
// Merge if adjacent (within 1 line gap)
if r.Line <= cur.endLine+1 {
if r.Line > cur.endLine {
cur.endLine = r.Line
}
} else {
clusters = append(clusters, cur)
cur = clusterEntry{startLine: r.Line, endLine: r.Line, firstText: r.MatchText}
}
}
clusters = append(clusters, cur)
for _, c := range clusters {
text := truncateLine(c.firstText, 200)
if c.startLine == c.endLine {
fmt.Fprintf(sb, "L%d│ %s\n", c.startLine, text)
} else {
fmt.Fprintf(sb, "L%d-%d│ %s\n", c.startLine, c.endLine, text)
}
}
}
// 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 {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
}
+130 -10
View File
@@ -8,10 +8,72 @@ import (
"path/filepath"
"strings"
"testing"
"unicode/utf8"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
)
// parseOutput must not attach one file's pending before-context to a match in a
// different file (cross-file leak guard in the JSON stream parser).
func TestParseOutputCrossFileContextNotLeaked(t *testing.T) {
s := newTestSearcher(t)
var buf bytes.Buffer
// fileA emits a before-context with no following match in fileA; then fileB
// has a match. fileA's pending context must NOT attach to fileB's match.
buf.WriteString(`{"type":"context","data":{"path":{"text":"fileA.go"},"lines":{"text":"orphan a ctx\n"},"line_number":1}}` + "\n")
buf.WriteString(`{"type":"match","data":{"path":{"text":"fileB.go"},"lines":{"text":"hit B\n"},"line_number":5,"submatches":[{"match":{"text":"hit"},"start":0,"end":3}]}}` + "\n")
res, err := s.parseOutput(&buf, 0)
if err != nil {
t.Fatalf("parseOutput: %v", err)
}
if len(res.Results) != 1 {
t.Fatalf("want 1 result, got %d", len(res.Results))
}
if res.Results[0].File != "fileB.go" {
t.Errorf("result file = %q, want fileB.go", res.Results[0].File)
}
if len(res.Results[0].Context.Before) != 0 {
t.Errorf("fileA context leaked into fileB match: %v", res.Results[0].Context.Before)
}
}
// parseOutput must attach a same-file before-context to the following match.
func TestParseOutputSameFileBeforeContext(t *testing.T) {
s := newTestSearcher(t)
var buf bytes.Buffer
buf.WriteString(`{"type":"context","data":{"path":{"text":"f.go"},"lines":{"text":"before line\n"},"line_number":4}}` + "\n")
buf.WriteString(`{"type":"match","data":{"path":{"text":"f.go"},"lines":{"text":"the match\n"},"line_number":5,"submatches":[{"match":{"text":"match"},"start":4,"end":9}]}}` + "\n")
res, err := s.parseOutput(&buf, 0)
if err != nil {
t.Fatalf("parseOutput: %v", err)
}
if len(res.Results) != 1 {
t.Fatalf("want 1 result, got %d", len(res.Results))
}
got := res.Results[0].Context.Before
if len(got) != 1 || got[0] != "before line" {
t.Errorf("same-file before-context not attached: %v", got)
}
}
// 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) {
cfg := config.Default()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
@@ -43,9 +105,10 @@ func TestBuildArgs(t *testing.T) {
}
tests := []struct {
name string
req *Request
expected []string
name string
req *Request
expected []string
notExpected []string // T-06: verify absence of unexpected flags
}{
{
name: "basic search",
@@ -54,7 +117,8 @@ func TestBuildArgs(t *testing.T) {
ContextLines: 2,
Regex: true,
},
expected: []string{"--json", "--context=2", "--", "test", "."},
expected: []string{"--json", "--context=2", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=0"},
},
{
name: "ignore case",
@@ -63,7 +127,8 @@ func TestBuildArgs(t *testing.T) {
IgnoreCase: true,
Regex: true,
},
expected: []string{"--json", "--ignore-case", "--", "test", "."},
expected: []string{"--json", "--ignore-case", "--", "test", "."},
notExpected: []string{"--fixed-strings"},
},
{
name: "fixed strings",
@@ -71,7 +136,8 @@ func TestBuildArgs(t *testing.T) {
Pattern: "test",
Regex: false,
},
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
notExpected: []string{"--ignore-case"},
},
{
name: "with file types",
@@ -80,7 +146,8 @@ func TestBuildArgs(t *testing.T) {
FileTypes: []string{"go", "ts"},
Regex: true,
},
expected: []string{"--json", "--type", "go", "--type", "ts", "--", "test", "."},
expected: []string{"--json", "--type", "go", "--type", "ts", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings"},
},
{
name: "with max results",
@@ -89,7 +156,8 @@ func TestBuildArgs(t *testing.T) {
MaxResults: 10,
Regex: true,
},
expected: []string{"--json", "--max-count=10", "--", "test", "."},
expected: []string{"--json", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=10", "--max-count=10"},
},
}
@@ -110,6 +178,16 @@ func TestBuildArgs(t *testing.T) {
t.Errorf("expected arg %q not found in %v", exp, args)
}
}
// T-06: Check that unexpected args are absent
for _, notExp := range tt.notExpected {
for _, arg := range args {
if arg == notExp {
t.Errorf("unexpected arg %q found in %v", notExp, args)
break
}
}
}
})
}
}
@@ -259,7 +337,7 @@ func TestSearchIntegration(t *testing.T) {
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Create test files
testFile := filepath.Join(tmpDir, "test.go")
@@ -269,7 +347,7 @@ func main() {
println("Hello, World!")
}
`
err = os.WriteFile(testFile, []byte(content), 0600)
err = os.WriteFile(testFile, []byte(content), 0o600)
if err != nil {
t.Fatalf("failed to write test file: %v", err)
}
@@ -324,3 +402,45 @@ func TestSearchEmptyPattern(t *testing.T) {
t.Error("expected error for empty pattern")
}
}
// TestSearchSymlinkEscapeNotLeaked is a security regression guard: with
// FollowSymlinks on (the default), ripgrep can traverse a symlink whose target
// is outside the workspace root. Such matches must never be returned — search
// must not leak the content of files outside the workspace.
func TestSearchSymlinkEscapeNotLeaked(t *testing.T) {
const secret = "SUPERSECRETLEAKCANARY"
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte(secret+"\n"), 0o600); err != nil {
t.Fatal(err)
}
workspace := t.TempDir()
// A regular in-workspace file that does NOT contain the secret.
if err := os.WriteFile(filepath.Join(workspace, "ok.txt"), []byte("nothing here\n"), 0o600); err != nil {
t.Fatal(err)
}
// A symlink inside the workspace pointing at the outside directory.
if err := os.Symlink(outside, filepath.Join(workspace, "escape")); err != nil {
t.Skipf("symlinks unsupported: %v", err)
}
cfg := config.Default()
cfg.WorkspaceRoot = workspace
if !cfg.FollowSymlinks {
t.Fatal("precondition: FollowSymlinks should default true so rg --follow is exercised")
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
s, err := New(cfg, logger)
if err != nil {
t.Skip("ripgrep not installed")
}
results, err := s.Search(context.Background(), &Request{Pattern: secret})
if err != nil {
t.Fatalf("search: %v", err)
}
for _, r := range results.Results {
t.Errorf("symlink escape leaked an out-of-workspace match: file=%q text=%q", r.File, r.MatchText)
}
}
+495
View File
@@ -0,0 +1,495 @@
package server
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/mark3labs/mcp-go/mcp"
)
// newTestServer creates a server with a temp workspace containing Go files.
func newFeaturesServer(t *testing.T) (*Server, string) {
t.Helper()
tmpDir := t.TempDir()
// Write a few Go files for AST queries
goFile1 := filepath.Join(tmpDir, "a.go")
if err := os.WriteFile(goFile1, []byte(`package main
func Alpha() string { return "alpha" }
func Beta() string { return "beta" }
func Gamma() string { return "gamma" }
`), 0o600); err != nil {
t.Fatal(err)
}
goFile2 := filepath.Join(tmpDir, "b.go")
if err := os.WriteFile(goFile2, []byte(`package main
func Delta() int { return 1 }
func Epsilon() int { return 2 }
`), 0o600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: config.DefaultMaxFileSize,
MaxParseSize: config.DefaultMaxParseSize,
SearchTimeout: 30 * time.Second,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error: %v", err)
}
return srv, tmpDir
}
// ---- Feature 1: ast_query format flag ----
func TestASTQueryFormatCompactDefault(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
// Default (no format arg) is compact: one line per match, no code blocks.
args := map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
}
req := mcp.CallToolRequest{}
req.Params.Arguments = args
res, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res == nil || len(res.Content) == 0 {
t.Fatal("nil/empty result")
}
if text := res.Content[0].(mcp.TextContent).Text; strings.Contains(text, "```") {
t.Errorf("compact default should NOT 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)
}
}
func TestASTQueryFormatCompact(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
"format": "compact",
}
res, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if strings.Contains(text, "```") {
t.Errorf("compact mode should NOT have code blocks, got:\n%s", text)
}
// Each line should contain file:line (kind) text
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
if line == "" || strings.HasPrefix(line, "Found") {
continue
}
if !strings.Contains(line, ":") {
t.Errorf("compact line missing ':' separator: %q", line)
}
}
}
func TestASTQueryFormatLocation(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
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("unexpected error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if strings.Contains(text, "```") {
t.Errorf("location mode should NOT have code blocks, got:\n%s", text)
}
// Lines should be file:linenum only (no parentheses with kind)
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
if line == "" || strings.HasPrefix(line, "Found") {
continue
}
if strings.Contains(line, "(") {
t.Errorf("location mode should not have node type in parens: %q", line)
}
}
}
// 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)
}
}
// TestRelPath verifies workspace-relative display paths (token saving) with a
// safe fallback to the original path for anything outside the root.
func TestRelPath(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
if got := srv.relPath(filepath.Join(tmpDir, "pkg", "a.go")); got != filepath.Join("pkg", "a.go") {
t.Errorf("in-workspace path should be relativized, got %q", got)
}
// Outside the workspace root → returned unchanged (never a ../ escape string).
outside := "/definitely/outside/x.go"
if got := srv.relPath(outside); got != outside {
t.Errorf("out-of-workspace path should be returned as-is, got %q", got)
}
}
// ---- Feature 3 (ast_query): pagination cursor ----
// TestASTQueryFirstPageCursor is a strict regression guard: page 1 must emit a
// cursor with an accurate remaining count when more matches exist. Previously
// the page-1 walk was capped at max_results, so the total was truncated and the
// cursor fired nondeterministically (and with a wrong remaining count).
func TestASTQueryFirstPageCursor(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
// Fixture has 5 functions (Alpha/Beta/Gamma/Delta/Epsilon); cap at 2.
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() $RET",
"language": "go",
"paths": []interface{}{tmpDir},
"max_results": float64(2),
}
res, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("page1 error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "[cursor:") {
t.Fatalf("page1 with 5 matches capped at 2 must emit a cursor, got:\n%s", text)
}
// 5 total - 2 shown = 3 remaining.
if !strings.Contains(text, "remaining: 3") {
t.Errorf("expected accurate 'remaining: 3', got:\n%s", text)
}
}
func TestASTQueryPaginationCursor(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
// Page 1: max_results=2
req1 := mcp.CallToolRequest{}
req1.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() $RET",
"language": "go",
"paths": []interface{}{tmpDir},
"max_results": float64(2),
}
res1, err := srv.handleASTQuery(ctx, req1)
if err != nil {
t.Fatalf("page1 error: %v", err)
}
text1 := res1.Content[0].(mcp.TextContent).Text
// Should contain cursor footer if there are more results
if !strings.Contains(text1, "[cursor:") {
// Might have fewer than 2 total results — skip cursor test
t.Logf("no cursor footer (fewer than 2 total matches), skipping pagination round-trip")
return
}
// Extract cursor token
var cursorToken string
for _, line := range strings.Split(text1, "\n") {
if strings.HasPrefix(line, "[cursor:") {
// [cursor: <token>, remaining: N]
parts := strings.Split(line, " ")
if len(parts) >= 2 {
cursorToken = strings.TrimSuffix(parts[1], ",")
}
break
}
}
if cursorToken == "" {
t.Fatal("could not extract cursor token from output")
}
// Page 2: pass cursor back
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() $RET",
"language": "go",
"paths": []interface{}{tmpDir},
"max_results": float64(2),
"cursor": cursorToken,
}
res2, err := srv.handleASTQuery(ctx, req2)
if err != nil {
t.Fatalf("page2 error: %v", err)
}
text2 := res2.Content[0].(mcp.TextContent).Text
if strings.Contains(text2, "cursor is for a different query") {
t.Error("cursor was rejected as mismatched query")
}
// Page 2 should have results
if strings.Contains(text2, "No matches found.") {
t.Error("page2 should have some results")
}
}
func TestASTQueryCursorStaleMismatch(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
"cursor": "eyJvZmZzZXQiOjIsInF1ZXJ5X2hhc2giOiJkZWFkYmVlZiJ9", // offset=2, hash=deadbeef
}
res, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "cursor is for a different query") {
t.Errorf("expected stale cursor error, got:\n%s", text)
}
}
// ---- Feature 2: file_search cluster ----
func TestFileSearchClusterFlag(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
if srv.searcher == nil {
t.Skip("rg not available")
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
"cluster": true,
}
res, err := srv.handleFileSearch(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res == nil || len(res.Content) == 0 {
t.Fatal("nil/empty result")
}
text := res.Content[0].(mcp.TextContent).Text
if text == "No matches found." {
t.Skip("no matches found (unexpected)")
}
// In cluster mode, should NOT have " │" context decorations
if strings.Contains(text, " │") {
t.Errorf("cluster mode should not contain context-line decoration ' │', got:\n%s", text)
}
}
func TestFileSearchCursorStaleHash(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
if srv.searcher == nil {
t.Skip("rg not available")
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
"cursor": "eyJvZmZzZXQiOjEsInF1ZXJ5X2hhc2giOiJiYWRoYXNoIn0", // offset=1, hash=badhash
}
res, err := srv.handleFileSearch(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "cursor is for a different query") {
t.Errorf("expected stale cursor error, got:\n%s", text)
}
}
// 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) {
srv, tmpDir := newFeaturesServer(t)
if srv.searcher == nil {
t.Skip("rg not available")
}
ctx := context.Background()
// Page 1: get 1 result
req1 := mcp.CallToolRequest{}
req1.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
"max_results": float64(1),
"context_lines": float64(0),
}
res1, err := srv.handleFileSearch(ctx, req1)
if err != nil {
t.Fatalf("page1 error: %v", err)
}
text1 := res1.Content[0].(mcp.TextContent).Text
if !strings.Contains(text1, "[cursor:") {
t.Logf("no cursor in page1 (only 1 total result), skipping round-trip:\n%s", text1)
return
}
// Extract cursor
var cursorToken string
for _, line := range strings.Split(text1, "\n") {
if strings.HasPrefix(line, "[cursor:") {
parts := strings.Split(line, " ")
if len(parts) >= 2 {
cursorToken = strings.TrimSuffix(parts[1], ",")
}
break
}
}
if cursorToken == "" {
t.Fatal("could not extract cursor from page1")
}
// Page 2
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
"max_results": float64(1),
"context_lines": float64(0),
"cursor": cursorToken,
}
res2, err := srv.handleFileSearch(ctx, req2)
if err != nil {
t.Fatalf("page2 error: %v", err)
}
text2 := res2.Content[0].(mcp.TextContent).Text
if strings.Contains(text2, "cursor is for a different query") {
t.Error("cursor was rejected as mismatched")
}
if strings.Contains(text2, "No matches found.") {
t.Error("page2 should have matches")
}
}
+351
View File
@@ -0,0 +1,351 @@
// Package server implements the MCP server for file operations.
package server
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/internal/cursor"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/internal/query"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
"github.com/mark3labs/mcp-go/mcp"
)
// astQueryParams holds resolved parameters for an AST query invocation.
type astQueryParams struct {
pattern string
language string
nameMatches string
nameExact string
kindIn []string
paths []string
maxResults int
format string
offset int
queryHash string
verbose bool
}
// resolveASTQueryParams parses the request and resolves session-pref defaults.
// Returns (params, errorResult, error); when errorResult is non-nil, the caller
// should return it directly.
func (s *Server) resolveASTQueryParams(request mcp.CallToolRequest) (*astQueryParams, *mcp.CallToolResult) {
pattern, err := request.RequireString("pattern")
if err != nil {
return nil, mcp.NewToolResultError("pattern is required")
}
language, err := request.RequireString("language")
if err != nil {
return nil, mcp.NewToolResultError("language is required")
}
p := &astQueryParams{
pattern: pattern,
language: language,
nameMatches: request.GetString("name_matches", ""),
nameExact: request.GetString("name_exact", ""),
kindIn: request.GetStringSlice("kind_in", nil),
paths: request.GetStringSlice("paths", nil),
verbose: request.GetBool("verbose", false),
}
sp := s.sessionPrefs.Load()
var prefsMaxResults int
var prefsFormat string
if sp != nil {
prefsMaxResults = sp.DefaultMaxResults
prefsFormat = sp.ASTQueryFormat
}
p.maxResults = effectiveInt(request, "max_results", prefsMaxResults, 100)
p.format = request.GetString("format", "")
if p.format == "" {
if prefsFormat != "" {
p.format = prefsFormat
} else {
// 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"
}
}
p.queryHash = cursor.HashParams(map[string]string{
"pattern": p.pattern,
"language": p.language,
"name_matches": p.nameMatches,
"name_exact": p.nameExact,
"kind_in": strings.Join(p.kindIn, ","),
"paths": strings.Join(p.paths, ","),
})
if cursorStr := request.GetString("cursor", ""); cursorStr != "" {
off, hash, decErr := cursor.Decode(cursorStr)
if decErr != nil {
return nil, mcp.NewToolResultError(fmt.Sprintf("invalid cursor: %s", decErr))
}
if hash != p.queryHash {
return nil, mcp.NewToolResultError("cursor is for a different query, re-run without cursor")
}
p.offset = off
}
return p, nil
}
// runASTQueryWalk walks the configured paths and collects matches.
func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []string) []query.MatchResult {
astQuery := &query.ASTQuery{
Pattern: p.pattern,
Language: p.language,
Filters: query.QueryFilters{
NameMatches: p.nameMatches,
NameExact: p.nameExact,
KindIn: p.kindIn,
},
}
// A full walk is required on every page: the cursor's "remaining" count is
// derived from the total match count (see buildASTCursorFooter), so the page-1
// response can only emit a correct cursor if it has seen every match. Capping
// page 1 at max_results made the cursor fire nondeterministically (only when
// the last parsed file overshot the cap) with a wrong remaining count. Parse
// results are LRU-cached, so re-walking on later pages is mostly file I/O, and
// matches below max_results already visit the whole tree regardless.
var allResults []query.MatchResult
for _, searchPath := range p.paths {
if !s.cfg.IsPathAllowed(searchPath) {
continue
}
walkErr := filepath.Walk(searchPath, func(path string, info os.FileInfo, err error) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err != nil {
return nil
}
if info.IsDir() {
// 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 nil
}
if !hasAnySuffix(path, exts) {
return nil
}
// Skip oversized files before reading them off disk.
if info.Size() > s.cfg.MaxFileSize {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return nil
}
result, err := s.parser.Parse(ctx, path, content)
if err != nil {
return nil
}
matches, err := s.matcher.Match(ctx, astQuery, result.Tree, content, path)
if err != nil {
return nil
}
allResults = append(allResults, matches...)
return nil
})
if walkErr != nil {
s.logger.Warn("error walking path", "path", searchPath, "error", walkErr)
}
}
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.
func hasAnySuffix(path string, suffixes []string) bool {
for _, ext := range suffixes {
if strings.HasSuffix(path, ext) {
return true
}
}
return false
}
// buildASTCursorFooter computes the cursor footer line for truncated results.
func buildASTCursorFooter(total, offset, maxResults int, queryHash string) string {
if offset > 0 && offset < total {
totalAfterOffset := total - offset
if maxResults > 0 && totalAfterOffset > maxResults {
remaining := totalAfterOffset - maxResults
nextOffset := offset + maxResults
nextCursor := cursor.Encode(nextOffset, queryHash)
return fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
}
} else if offset == 0 && maxResults > 0 && total > maxResults {
remaining := total - maxResults
nextCursor := cursor.Encode(maxResults, queryHash)
return fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
}
return ""
}
// handleASTQuery handles the ast_query tool.
func (s *Server) handleASTQuery(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Acquire semaphore to limit concurrent queries (prevents CPU exhaustion)
select {
case s.querySem <- struct{}{}:
defer func() { <-s.querySem }()
case <-ctx.Done():
return mcp.NewToolResultError("request cancelled"), nil
}
p, errResult := s.resolveASTQueryParams(request)
if errResult != nil {
return errResult, nil
}
if len(p.paths) == 0 {
p.paths = []string{s.cfg.WorkspaceRoot}
}
exts := languageToExtensions(p.language)
if len(exts) == 0 {
return mcp.NewToolResultError(fmt.Sprintf("unsupported language: %s (supported: go, typescript, javascript, python, c, cpp, html, vue, elixir, rust)", p.language)), nil
}
allResults := s.runASTQueryWalk(ctx, p, exts)
cursorFooter := buildASTCursorFooter(len(allResults), p.offset, p.maxResults, p.queryHash)
// The formatter emits cursorFooter in place of its "[remaining: N]" hint when
// results are truncated; no fragile placeholder string-replacement needed.
output := query.FormatResultsWithCursor(allResults, p.maxResults, p.format, p.offset, p.verbose, cursorFooter)
return mcp.NewToolResultText(output), nil
}
// generateASTSummary generates a summary of symbols in the file.
func (s *Server) generateASTSummary(ctx context.Context, path string, content []byte) string {
// Parse the file
result, err := s.parser.Parse(ctx, path, content)
if err != nil {
return "" // Silently skip AST if parsing fails
}
// Extract symbols
lang := protocol.DetectLanguage(path)
symbols := parser.ExtractSymbols(result.Tree, content, lang, path)
if len(symbols) == 0 {
return ""
}
var sb strings.Builder
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", s.relPath(path), countLines(content), lang)
sb.WriteString("Symbols:\n")
for _, sym := range symbols {
kindStr := symbolKindIcon(sym.Kind)
fmt.Fprintf(&sb, " %s %s L%d\n", kindStr, sym.Name, sym.Location.Line)
}
return sb.String()
}
// relPath renders path relative to the workspace root for display, saving the
// tokens an absolute path would cost. Falls back to the original path when it
// can't be relativized (outside the root, or on error).
func (s *Server) relPath(path string) string {
if absPath, err := filepath.Abs(path); err == nil {
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
return rel
}
}
return path
}
// symbolKindIcon returns an icon/prefix for a symbol kind.
func symbolKindIcon(kind protocol.SymbolKind) string {
switch kind {
case protocol.SymbolFunction:
return "func"
case protocol.SymbolMethod:
return "meth"
case protocol.SymbolClass:
return "class"
case protocol.SymbolStruct:
return "struct"
case protocol.SymbolInterface:
return "iface"
case protocol.SymbolVariable:
return "var"
case protocol.SymbolConstant:
return "const"
case protocol.SymbolType:
return "type"
case protocol.SymbolField:
return "field"
case protocol.SymbolProperty:
return "prop"
case protocol.SymbolModule:
return "mod"
case protocol.SymbolPackage:
return "pkg"
case protocol.SymbolEnum:
return "enum"
case protocol.SymbolTrait:
return "trait"
default:
return "sym"
}
}
// languageToExtensions maps language names to file extensions.
func languageToExtensions(language string) []string {
switch strings.ToLower(language) {
case "go":
return []string{".go"}
case "typescript":
return []string{".ts"}
case "javascript":
return []string{".js"}
case "python":
return []string{".py"}
case "c":
return []string{".c"}
case "cpp", "c++":
return []string{".cpp"}
case "html":
return []string{".html", ".htm"}
case "vue":
return []string{".vue"}
case "elixir":
return []string{".ex", ".exs"}
case "rust":
return []string{".rs"}
default:
return nil
}
}
+133
View File
@@ -0,0 +1,133 @@
// Package server implements the MCP server for file operations.
package server
import (
"context"
"fmt"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/internal/edit"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/mark3labs/mcp-go/mcp"
)
// handleEditApply handles the edit_apply tool.
func (s *Server) handleEditApply(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return s.handleEdit(ctx, request)
}
// handleEdit performs an edit operation (always applies changes).
func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
file, err := request.RequireString("file")
if err != nil {
return mcp.NewToolResultError("file is required"), nil
}
operation, err := request.RequireString("operation")
if err != nil {
return mcp.NewToolResultError("operation is required"), nil
}
switch edit.EditOperation(operation) {
case edit.EditReplace, edit.EditInsertBefore, edit.EditInsertAfter, edit.EditDelete:
// valid
default:
return mcp.NewToolResultError(fmt.Sprintf(
"invalid operation %q: must be one of: replace, insert_before, insert_after, delete", operation,
)), nil
}
if !s.cfg.IsPathAllowed(file) {
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", "")
selectorName := request.GetString("selector_name", "")
astEdit := &edit.ASTEdit{
File: file,
Operation: edit.EditOperation(operation),
NewContent: newContent,
Selector: edit.ASTSelector{
Kind: request.GetString("selector_kind", ""),
Name: selectorName,
AtLine: request.GetInt("selector_line", 0),
Index: request.GetInt("selector_index", 0),
LineEnd: request.GetInt("selector_line_end", 0),
Text: request.GetString("selector_text", ""),
TextPattern: request.GetString("selector_pattern", ""),
},
}
result, err := s.editor.Apply(ctx, astEdit)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("edit failed: %s", errors.SanitizeError(err))), nil
}
if !result.Success {
return mcp.NewToolResultError(result.Error), nil
}
// Determine response mode.
// compact_response is a deprecated alias for response="count".
respMode := request.GetString("response", "count")
if request.GetBool("compact_response", false) {
// Deprecated: use response=count
respMode = "count"
}
switch respMode {
case "none":
return mcp.NewToolResultText(""), nil
case "count":
// Compute +added/-removed line counts from the unified diff.
added, removed := countDiffLines(result.Diff)
return mcp.NewToolResultText(fmt.Sprintf("+%d -%d", added, removed)), nil
case "diff":
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
default:
// Fallback: treat unknown values as "diff" for safety.
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
}
}
// fencedDiff wraps a unified diff in a ```diff code fence. No "Diff:" label is
// added — the fence and the diff's own ---/+++ headers already identify it, so
// the label would only cost tokens.
func fencedDiff(diff string) string {
return "```diff\n" + diff + "```\n"
}
// 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) {
inHunk := false
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, "@@") {
inHunk = true
continue
}
if !inHunk || line == "" {
continue
}
switch line[0] {
case '+':
added++
case '-':
removed++
}
}
return
}
+130
View File
@@ -0,0 +1,130 @@
package server
import (
"context"
"os"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
// callEdit invokes handleEditApply with the given args and returns the result.
func callEdit(t *testing.T, srv *Server, args map[string]any) *mcp.CallToolResult {
t.Helper()
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)
}
got, err := os.ReadFile(f)
if err != nil {
t.Fatalf("read back: %v", err)
}
want := newContent + "\n"
if string(got) != want {
t.Fatalf("new_content not written verbatim (double-unescape regression).\nwant: %q\ngot: %q", want, string(got))
}
}
// TestEditApplyDiffResponseFencedNoLabel verifies response=diff emits the unified
// diff in a ```diff fence with no redundant "Diff:" label (the fence and the diff's
// own headers already identify it; the label would only cost tokens).
func TestEditApplyDiffResponseFencedNoLabel(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "note.txt", "before\n")
res := callEdit(t, srv, map[string]any{
"file": f,
"operation": "replace",
"selector_text": "before",
"new_content": "after",
"response": "diff",
})
if res.IsError {
t.Fatalf("edit returned error: %+v", res.Content)
}
out := res.Content[0].(mcp.TextContent).Text
if !strings.HasPrefix(out, "```diff\n") {
t.Errorf("diff response should start with the ```diff fence, got:\n%s", out)
}
if strings.Contains(out, "Diff:") {
t.Errorf("diff response should not carry a redundant 'Diff:' label, got:\n%s", out)
}
}
// 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))
}
}
+690
View File
@@ -0,0 +1,690 @@
// Package server implements the MCP server for file operations.
package server
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
xxhash "github.com/cespare/xxhash/v2"
"github.com/lukaszraczylo/mcp-filepuff/internal/cursor"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/internal/search"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/fuzzy"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
"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.
func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
start := time.Now()
defer func() {
s.logger.Debug("file_search completed",
"duration_ms", time.Since(start).Milliseconds(),
)
}()
if s.searcher == nil {
return mcp.NewToolResultError("ripgrep (rg) is not available. Please install it: https://github.com/BurntSushi/ripgrep#installation"), nil
}
pattern, err := request.RequireString("pattern")
if err != nil {
return mcp.NewToolResultError("pattern is required"), nil
}
paths := request.GetStringSlice("paths", nil)
fileTypes := request.GetStringSlice("file_types", nil)
ignoreCase := request.GetBool("ignore_case", false)
regex := request.GetBool("regex", true)
contextLines := request.GetInt("context_lines", 2)
// Consult session prefs for max_results and cluster when not explicitly supplied.
prefs := s.sessionPrefs.Load()
var prefsMaxResults int
var prefsCluster *bool
if prefs != nil {
prefsMaxResults = prefs.DefaultMaxResults
prefsCluster = prefs.DefaultCluster
}
// 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)
cursorStr := request.GetString("cursor", "")
// Compute query hash for cursor validation.
queryHash := cursor.HashParams(map[string]string{
"pattern": pattern,
"paths": strings.Join(paths, ","),
"file_types": strings.Join(fileTypes, ","),
"ignore_case": strconv.FormatBool(ignoreCase),
"regex": strconv.FormatBool(regex),
"context_lines": strconv.Itoa(contextLines),
})
// Resolve cursor offset.
offset := 0
if cursorStr != "" {
off, hash, decErr := cursor.Decode(cursorStr)
if decErr != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid cursor: %s", decErr)), nil
}
if hash != queryHash {
return mcp.NewToolResultError("cursor is for a different query, re-run without cursor"), nil
}
offset = off
}
// When paginating with a cursor, fetch all results (no rg-level cap) so we
// can apply the offset in-process. Without a cursor, let rg cap at maxResults.
rgMaxResults := maxResults
if offset > 0 {
rgMaxResults = 0 // fetch all, apply cap after skipping
}
req := &search.Request{
Pattern: pattern,
Paths: paths,
FileTypes: fileTypes,
IgnoreCase: ignoreCase,
Regex: regex,
ContextLines: contextLines,
MaxResults: rgMaxResults,
IncludeHidden: request.GetBool("include_hidden", false),
}
results, err := s.searcher.Search(ctx, req)
if err != nil {
s.logger.Warn("search error", "error", err)
return mcp.NewToolResultError(fmt.Sprintf("search error: %s", errors.SanitizeError(err))), nil
}
// Apply cursor offset.
if offset > 0 && offset < len(results.Results) {
results.Results = results.Results[offset:]
results.Truncated = false // will re-evaluate below
} else if offset > 0 {
results.Results = nil
results.Truncated = false
}
// Apply in-process max_results cap and compute cursor footer.
var cursorLine string
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
results.Results = results.Results[:maxResults]
results.Truncated = true
nextOffset := offset + maxResults
nextCursor := cursor.Encode(nextOffset, queryHash)
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",
"pattern", pattern,
"results_count", len(results.Results),
"truncated", results.Truncated,
)
verbose := request.GetBool("verbose", false)
opts := search.FormatOptions{
Cluster: cluster,
CursorLine: cursorLine,
Verbose: verbose,
}
output := s.searcher.FormatResultsWithOptions(results, opts)
return mcp.NewToolResultText(output), nil
}
// effectiveInt returns the per-call value if the key is explicitly present in the
// request arguments, otherwise falls back to sessionDefault (if > 0), then builtIn.
func effectiveInt(request mcp.CallToolRequest, key string, sessionDefault, builtIn int) int {
if _, explicit := request.GetArguments()[key]; explicit {
return request.GetInt(key, builtIn)
}
if sessionDefault > 0 {
return sessionDefault
}
return builtIn
}
// effectiveBool returns the per-call value if the key is explicitly present in the
// request arguments, otherwise falls back to sessionDefault (if non-nil), then builtIn.
func effectiveBool(request mcp.CallToolRequest, key string, sessionDefault *bool, builtIn bool) bool {
if _, explicit := request.GetArguments()[key]; explicit {
return request.GetBool(key, builtIn)
}
if sessionDefault != nil {
return *sessionDefault
}
return builtIn
}
// handleFileRead handles the file_read tool.
func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
select {
case s.readSem <- struct{}{}:
defer func() { <-s.readSem }()
case <-ctx.Done():
return mcp.NewToolResultError("request cancelled"), nil
}
// Batch mode: paths[] takes precedence over path.
// NOTE: batch reads are always inlined — mixing dedup + resource_links is
// too complex and the savings are unclear for multi-file calls.
if paths := request.GetStringSlice("paths", nil); len(paths) > 0 {
var output strings.Builder
// Dedup: track etag -> first path that produced it.
seenEtag := make(map[string]string) // etag -> first path
for i, p := range paths {
if i > 0 {
output.WriteString("\n")
}
result, err := s.readOneFile(ctx, request, p)
if err != nil {
fmt.Fprintf(&output, "--- %s ---\n[error: %s]\n", p, errors.SanitizeError(err))
continue
}
// Extract etag from result footer for dedup check.
etag := extractEtag(result)
if etag != "" {
if firstPath, seen := seenEtag[etag]; seen {
// Duplicate content: emit pointer instead of full content.
fmt.Fprintf(&output, "--- %s ---\n[duplicate of %s, etag: %s]\n", p, firstPath, etag)
continue
}
seenEtag[etag] = p
}
fmt.Fprintf(&output, "--- %s ---\n%s", p, result)
}
return mcp.NewToolResultText(output.String()), nil
}
path := request.GetString("path", "")
if path == "" {
return mcp.NewToolResultError("path or paths is required"), nil
}
result, err := s.readOneFile(ctx, request, path)
if err != nil {
return mcp.NewToolResultError(errors.SanitizeError(err)), nil
}
// Resource-link threshold check: for single-file reads, if the result
// exceeds the configured threshold, return a ResourceLink instead of
// inlining the content. The client can fetch the resource on demand.
// Bypassed when:
// - force_inline=true
// - max_inline_bytes is set and result fits within it
// - threshold is 0 (disabled)
// - result is already small (skeleton/symbols_only/line-range paths
// produce small output; threshold is on result bytes, not file bytes)
// Determine resource-link threshold: session pref overrides cfg, per-call overrides session.
threshold := s.cfg.ResourceLinkThresholdBytes
if sp := s.sessionPrefs.Load(); sp != nil && sp.ResourceLinkThreshold > 0 {
threshold = sp.ResourceLinkThreshold
}
forceInline := request.GetBool("force_inline", false)
maxInlineBytes := request.GetInt("max_inline_bytes", 0)
if maxInlineBytes > 0 {
threshold = maxInlineBytes
}
if !forceInline && threshold > 0 && len(result) > threshold {
etag := extractEtag(result)
uri := buildReadResourceURI(path, etag)
lineCount := strings.Count(result, "\n")
desc := fmt.Sprintf("etag=%s, size=%d bytes, lines=%d", etag, len(result), lineCount)
mimeType := detectMIMEType(path)
link := mcp.NewResourceLink(uri, path, desc, mimeType)
return &mcp.CallToolResult{
Content: []mcp.Content{link},
}, nil
}
return mcp.NewToolResultText(result), nil
}
// buildReadResourceURI constructs the filepuff://read URI for a file + etag pair.
func buildReadResourceURI(path, etag string) string {
if etag == "" {
return "filepuff://read/" + path
}
return "filepuff://read/" + path + "?etag=" + etag
}
// detectMIMEType returns a best-effort MIME type for the given file path.
func detectMIMEType(path string) string {
ext := strings.ToLower(path)
switch {
case strings.HasSuffix(ext, ".go"):
return "text/x-go"
case strings.HasSuffix(ext, ".ts"), strings.HasSuffix(ext, ".tsx"):
return "text/typescript"
case strings.HasSuffix(ext, ".js"), strings.HasSuffix(ext, ".jsx"):
return "text/javascript"
case strings.HasSuffix(ext, ".py"):
return "text/x-python"
case strings.HasSuffix(ext, ".rs"):
return "text/x-rust"
case strings.HasSuffix(ext, ".md"):
return "text/markdown"
case strings.HasSuffix(ext, ".json"):
return "application/json"
case strings.HasSuffix(ext, ".yaml"), strings.HasSuffix(ext, ".yml"):
return "text/yaml"
case strings.HasSuffix(ext, ".toml"):
return "text/toml"
case strings.HasSuffix(ext, ".html"), strings.HasSuffix(ext, ".htm"):
return "text/html"
case strings.HasSuffix(ext, ".css"):
return "text/css"
case strings.HasSuffix(ext, ".sh"):
return "text/x-sh"
case strings.HasSuffix(ext, ".c"), strings.HasSuffix(ext, ".h"):
return "text/x-c"
case strings.HasSuffix(ext, ".cpp"), strings.HasSuffix(ext, ".cc"), strings.HasSuffix(ext, ".cxx"):
return "text/x-c++"
default:
return "text/plain"
}
}
// lineNumberOpts holds resolved line-numbering preferences for readOneFile.
type lineNumberOpts struct {
noLineNumbers bool
compactLineNums bool
lineInterval int
}
// resolveLineNumberOpts resolves per-call vs session-pref line-number options.
func (s *Server) resolveLineNumberOpts(request mcp.CallToolRequest) lineNumberOpts {
opts := lineNumberOpts{
noLineNumbers: request.GetBool("no_line_numbers", false),
lineInterval: request.GetInt("line_number_interval", 1),
// Compact prefix ("12│content") is the default — it drops the 4-wide
// padding and trailing space of the legacy " 12│ " form, saving ~3
// chars/line with no information loss. Pass compact_line_numbers=false
// (or mode/line_numbers="full") to restore the padded alignment.
compactLineNums: request.GetBool("compact_line_numbers", true),
}
// Apply session line_numbers pref when no explicit per-call override was supplied.
if sp := s.sessionPrefs.Load(); sp != nil && sp.LineNumbers != "" {
_, hasNoLN := request.GetArguments()["no_line_numbers"]
_, hasCompact := request.GetArguments()["compact_line_numbers"]
_, hasInterval := request.GetArguments()["line_number_interval"]
if !hasNoLN && !hasCompact && !hasInterval {
switch sp.LineNumbers {
case "none":
opts.noLineNumbers = true
opts.compactLineNums = false
case "compact":
opts.noLineNumbers = false
opts.compactLineNums = true
case "full":
opts.noLineNumbers = false
opts.compactLineNums = false
}
}
}
if opts.lineInterval == 0 {
opts.noLineNumbers = true
}
return opts
}
// applyStrip applies strip flags to the selected line range and returns the
// possibly-rewritten lines, new bounds, and a stripped-footer annotation.
func applyStrip(lines []string, lineStart, lineEnd int, stripFlags []parser.StripFlag, path string) (newLines []string, newStart, newEnd int, footer string) {
if len(stripFlags) == 0 {
return lines, lineStart, lineEnd, ""
}
selectedContent := strings.Join(lines[lineStart-1:lineEnd], "\n")
lang := protocol.DetectLanguage(path)
stripped := parser.StripContent(selectedContent, stripFlags, lang)
if len(stripped.Stripped) > 0 {
names := make([]string, len(stripped.Stripped))
for i, f := range stripped.Stripped {
names[i] = string(f)
}
footer = "[stripped: " + strings.Join(names, ", ") + "]\n"
}
newLines = splitLines(stripped.Content)
return newLines, 1, len(newLines), footer
}
// loadFileForRead performs workspace, stat, size, and read checks for a path.
func (s *Server) loadFileForRead(path string) ([]byte, error) {
if !s.cfg.IsPathAllowed(path) {
return nil, fmt.Errorf("path is outside workspace root")
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", path)
}
if os.IsPermission(err) {
return nil, fmt.Errorf("permission denied: %s", path)
}
s.logger.Warn("file stat error", "path", path, "error", err)
return nil, fmt.Errorf("error accessing file")
}
if info.Size() > s.cfg.MaxFileSize {
return nil, fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), s.cfg.MaxFileSize)
}
content, err := os.ReadFile(path)
if err != nil {
if os.IsPermission(err) {
return nil, fmt.Errorf("permission denied: %s", path)
}
s.logger.Warn("file read error", "path", path, "error", err)
return nil, fmt.Errorf("error reading file")
}
return content, nil
}
// readOneFile reads a single file applying all formatting options from the request.
func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, path string) (string, error) {
content, err := s.loadFileForRead(path)
if err != nil {
return "", err
}
// Feature 3: short etag — 8 hex chars (32-bit).
// Accept previous_etag by prefix match so old 16-char etags keep working.
fullHash := fmt.Sprintf("%016x", xxhash.Sum64(content))
etag := fullHash[:8]
if prev := request.GetString("previous_etag", ""); prev != "" {
// Match: exact 8-char match, old client sent full 16-char etag, or new client sent 8-char prefix of old.
if prev == etag || strings.HasPrefix(fullHash, prev) || strings.HasPrefix(prev, etag) {
return fmt.Sprintf("[unchanged, etag: %s]\n", etag), nil
}
}
// Parse request options
includeAST := request.GetBool("include_ast", false)
symbolsOnly := request.GetBool("symbols_only", false)
symbolName := request.GetString("symbol_name", "")
collapseBlank := request.GetBool("collapse_blank_lines", false)
maxLines := request.GetInt("max_lines", 0)
lnOpts := s.resolveLineNumberOpts(request)
// Feature 1: mode flag — "full" (default) | "skeleton" | "symbols_only".
// symbols_only mode is an alias for include_ast+symbols_only.
mode := request.GetString("mode", "full")
if mode == "symbols_only" {
symbolsOnly = true
includeAST = true
}
// Feature 2: strip — remove selected content classes before line-numbering.
stripRaw := request.GetStringSlice("strip", nil)
var stripFlags []parser.StripFlag
for _, sf := range stripRaw {
stripFlags = append(stripFlags, parser.StripFlag(sf))
}
if symbolsOnly && !includeAST {
return "", fmt.Errorf("symbols_only requires include_ast=true")
}
lines := splitLines(string(content))
lineStart := request.GetInt("line_start", 1)
lineEnd := request.GetInt("line_end", len(lines))
// Symbol-based line range: find the symbol and use its exact bounds.
if symbolName != "" {
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
candidates := s.resolveSymbolCandidates(ctx, path, content, symbolName, symbolKind)
switch len(candidates) {
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))
}
}
// Clamp to valid range.
if lineStart < 1 {
lineStart = 1
}
if lineEnd > len(lines) {
lineEnd = len(lines)
}
if lineStart > lineEnd {
lineStart = lineEnd
}
var output strings.Builder
// Feature 1: skeleton mode — replace function bodies with { ... }.
if mode == "skeleton" {
skText, _, skErr := parser.SkeletonFile(ctx, s.parser, path, content)
if skErr != nil {
// Fall back to full mode on parse error.
s.logger.Warn("skeleton mode failed, falling back to full", "path", path, "error", skErr)
} else {
output.WriteString(skText)
fmt.Fprintf(&output, "[etag: %s]\n", etag)
return output.String(), nil
}
}
if includeAST {
if summary := s.generateASTSummary(ctx, path, content); summary != "" {
output.WriteString(summary)
if !symbolsOnly {
output.WriteString("\n---\n\n")
}
}
}
if symbolsOnly {
fmt.Fprintf(&output, "[etag: %s]\n", etag)
return output.String(), nil
}
// Feature 2: apply strip AFTER line-range selection, BEFORE line numbering.
lines, lineStart, lineEnd, strippedFooter := applyStrip(lines, lineStart, lineEnd, stripFlags, path)
writeLines(&output, lines, lineStart, lineEnd, maxLines, lnOpts.noLineNumbers, lnOpts.lineInterval, collapseBlank, lnOpts.compactLineNums)
if strippedFooter != "" {
output.WriteString(strippedFooter)
}
fmt.Fprintf(&output, "[etag: %s]\n", etag)
return output.String(), nil
}
// resolveSymbolCandidates parses the AST and returns every symbol matching the
// name (and optional kind). Zero results means not found; more than one means an
// 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)
if err != nil {
return nil
}
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.
// compactLineNums=true emits "12│" instead of " 12│ " (no padding, no trailing space).
func writeLines(output *strings.Builder, lines []string, lineStart, lineEnd, maxLines int, noLineNumbers bool, lineInterval int, collapseBlank bool, compactLineNums bool) {
effectiveEnd := lineEnd
truncatedCount := 0
if maxLines > 0 && (lineEnd-lineStart+1) > maxLines {
effectiveEnd = lineStart + maxLines - 1
truncatedCount = lineEnd - effectiveEnd
}
prevBlank := false
for i := lineStart - 1; i < effectiveEnd && i < len(lines); i++ {
line := lines[i]
isBlank := strings.TrimSpace(line) == ""
if collapseBlank && isBlank && prevBlank {
continue
}
prevBlank = isBlank
lineNum := i + 1
switch {
case noLineNumbers:
output.WriteString(line + "\n")
case compactLineNums:
// Feature 4: compact prefix — "12│content"
if lineInterval <= 1 || lineNum%lineInterval == 0 || i == lineStart-1 || i == effectiveEnd-1 {
fmt.Fprintf(output, "%d│%s\n", lineNum, line)
} else {
fmt.Fprintf(output, "│%s\n", line)
}
case lineInterval <= 1 || lineNum%lineInterval == 0 || i == lineStart-1 || i == effectiveEnd-1:
fmt.Fprintf(output, "%4d│ %s\n", lineNum, line)
default:
fmt.Fprintf(output, " │ %s\n", line)
}
}
if truncatedCount > 0 {
fmt.Fprintf(output, "\n[... %d more lines omitted. Use line_start/line_end or increase max_lines to see more]\n", truncatedCount)
}
}
// extractEtag extracts the etag value from a readOneFile result string.
// Returns empty string if not found.
func extractEtag(result string) string {
// Look for "[etag: XXXXXXXX]" at end of result.
const prefix = "[etag: "
idx := strings.LastIndex(result, prefix)
if idx < 0 {
return ""
}
rest := result[idx+len(prefix):]
close := strings.Index(rest, "]")
if close < 0 {
return ""
}
return rest[:close]
}
// splitLines splits a string into lines.
// For large files (> 1MB), uses bufio.Scanner which is more memory efficient.
// 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 {
const largeSizeThreshold = 1024 * 1024 // 1MB
if len(s) > largeSizeThreshold {
scanner := bufio.NewScanner(strings.NewReader(s))
scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), 1024*1024)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return strings.Split(s, "\n")
}
if len(s) > 0 && s[len(s)-1] == '\n' {
lines = append(lines, "")
}
return lines
}
return strings.Split(s, "\n")
}
+935
View File
@@ -0,0 +1,935 @@
package server
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/mark3labs/mcp-go/mcp"
)
// newTestServer creates a minimal server pointing at tmpDir.
func newTestServer(t *testing.T, tmpDir string) *Server {
t.Helper()
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: 10 * 1024 * 1024, // 10 MB — required for file reads to succeed
MaxParseSize: 10 * 1024 * 1024,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
return srv
}
// callRead calls handleFileRead with the given args map and returns the text content.
func callRead(t *testing.T, srv *Server, args map[string]interface{}) string {
t.Helper()
req := mcp.CallToolRequest{}
req.Params.Arguments = args
result, err := srv.handleFileRead(context.Background(), req)
if err != nil {
t.Fatalf("handleFileRead error: %v", err)
}
if result == nil {
t.Fatal("handleFileRead returned nil")
}
if len(result.Content) == 0 {
t.Fatal("handleFileRead returned empty content")
}
tc, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("handleFileRead did not return TextContent")
}
return tc.Text
}
// callReadResult calls handleFileRead and returns the raw CallToolResult (not just text).
func callReadResult(t *testing.T, srv *Server, args map[string]interface{}) *mcp.CallToolResult {
t.Helper()
req := mcp.CallToolRequest{}
req.Params.Arguments = args
result, err := srv.handleFileRead(context.Background(), req)
if err != nil {
t.Fatalf("handleFileRead error: %v", err)
}
if result == nil {
t.Fatal("handleFileRead returned nil")
}
return result
}
// newTestServerWithThreshold creates a server with a custom ResourceLinkThresholdBytes.
func newTestServerWithThreshold(t *testing.T, tmpDir string, thresholdBytes int) *Server {
t.Helper()
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: 10 * 1024 * 1024,
MaxParseSize: 10 * 1024 * 1024,
ResourceLinkThresholdBytes: thresholdBytes,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
return srv
}
// writeFile writes content to a file in tmpDir and returns its absolute path.
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0600); err != nil {
t.Fatalf("WriteFile(%s): %v", name, err)
}
return p
}
// ---- Feature 1: skeleton mode ----
func TestSkeletonModeGo(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `package main
// Hello says hello
func Hello() {
println("Hello, World!")
println("more body")
}
func Add(a, b int) int {
return a + b
}
`
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"mode": "skeleton",
})
// Should contain function signatures
if !strings.Contains(out, "func Hello()") {
t.Errorf("skeleton output missing Hello signature, got:\n%s", out)
}
if !strings.Contains(out, "func Add(") {
t.Errorf("skeleton output missing Add signature, got:\n%s", out)
}
// Should NOT contain body contents
if strings.Contains(out, `println("more body")`) {
t.Errorf("skeleton output should not contain body contents, got:\n%s", out)
}
// Should contain placeholder
if !strings.Contains(out, "{ ... }") {
t.Errorf("skeleton output missing { ... } placeholder, got:\n%s", out)
}
// Should contain etag footer
if !strings.Contains(out, "[etag:") {
t.Errorf("skeleton output missing etag footer, got:\n%s", out)
}
}
func TestSkeletonModeFullFlagAlias(t *testing.T) {
// mode="full" should behave identically to not specifying mode
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\nfunc F() { println(1) }\n"
f := writeFile(t, tmpDir, "test.go", goSrc)
outFull := callRead(t, srv, map[string]interface{}{"path": f, "mode": "full"})
outDefault := callRead(t, srv, map[string]interface{}{"path": f})
// Both should have same content (etag will be same, line content same)
if outFull != outDefault {
t.Errorf("mode=full differs from default\nfull: %q\ndefault: %q", outFull, outDefault)
}
}
func TestSkeletonModeSymbolsOnlyAlias(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\nfunc F() { println(1) }\n"
f := writeFile(t, tmpDir, "test.go", goSrc)
// mode=symbols_only should return symbols summary (needs include_ast implicitly)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"mode": "symbols_only",
})
// Should contain etag but NOT the function body
if !strings.Contains(out, "[etag:") {
t.Errorf("symbols_only output missing etag, got:\n%s", out)
}
if strings.Contains(out, "println") {
t.Errorf("symbols_only should not contain body, got:\n%s", out)
}
}
func TestSkeletonModeTypeScript(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
tsSrc := `// a function
function greet(name: string): string {
return "Hello " + name;
}
class Greeter {
greet(name: string) {
return "hi " + name;
}
}
`
f := writeFile(t, tmpDir, "test.ts", tsSrc)
out := callRead(t, srv, map[string]interface{}{"path": f, "mode": "skeleton"})
if !strings.Contains(out, "function greet") {
t.Errorf("TS skeleton missing function signature, got:\n%s", out)
}
if !strings.Contains(out, "{ ... }") {
t.Errorf("TS skeleton missing placeholder, got:\n%s", out)
}
if strings.Contains(out, `"Hello " + name`) {
t.Errorf("TS skeleton should not contain body, got:\n%s", out)
}
}
func TestSkeletonModePython(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
pySrc := `def greet(name):
print("Hello " + name)
print("extra line")
class Foo:
def bar(self):
return 42
`
f := writeFile(t, tmpDir, "test.py", pySrc)
out := callRead(t, srv, map[string]interface{}{"path": f, "mode": "skeleton"})
if !strings.Contains(out, "def greet") {
t.Errorf("Python skeleton missing greet signature, got:\n%s", out)
}
if strings.Contains(out, "extra line") {
t.Errorf("Python skeleton should not contain body, got:\n%s", out)
}
// Python uses "..." as placeholder
if !strings.Contains(out, "...") {
t.Errorf("Python skeleton missing ... placeholder, got:\n%s", out)
}
}
func TestSkeletonModeRust(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
rsSrc := `fn add(a: i32, b: i32) -> i32 {
let result = a + b;
result
}
struct Foo {
x: i32,
}
`
f := writeFile(t, tmpDir, "test.rs", rsSrc)
out := callRead(t, srv, map[string]interface{}{"path": f, "mode": "skeleton"})
if !strings.Contains(out, "fn add(") {
t.Errorf("Rust skeleton missing fn signature, got:\n%s", out)
}
if !strings.Contains(out, "{ ... }") {
t.Errorf("Rust skeleton missing placeholder, got:\n%s", out)
}
if strings.Contains(out, "let result") {
t.Errorf("Rust skeleton should not contain body, got:\n%s", out)
}
}
// ---- 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) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("hello")
}
`
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"imports"},
})
if strings.Contains(out, `"fmt"`) {
t.Errorf("strip=imports should remove import block, got:\n%s", out)
}
if !strings.Contains(out, "func main") {
t.Errorf("strip=imports should keep function, got:\n%s", out)
}
if !strings.Contains(out, "[stripped: imports]") {
t.Errorf("strip footer missing, got:\n%s", out)
}
}
func TestStripLicenseGoBlockComment(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `/* Copyright 2024 Acme Corp. All rights reserved.
License: MIT
*/
package main
func main() {}
`
f := writeFile(t, tmpDir, "main.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"license"},
})
if strings.Contains(out, "Copyright") {
t.Errorf("strip=license should remove license comment, got:\n%s", out)
}
if !strings.Contains(out, "func main") {
t.Errorf("strip=license should keep code, got:\n%s", out)
}
if !strings.Contains(out, "[stripped: license]") {
t.Errorf("license strip footer missing, got:\n%s", out)
}
}
func TestStripBlockCommentsGo(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `package main
/* This is a block comment
spanning multiple lines */
func main() {
/* inline block */
println("hi")
}
`
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"block_comments"},
})
if strings.Contains(out, "This is a block comment") {
t.Errorf("strip=block_comments should remove block comments, got:\n%s", out)
}
if strings.Contains(out, "inline block") {
t.Errorf("strip=block_comments should remove inline block comment, got:\n%s", out)
}
if !strings.Contains(out, "func main") {
t.Errorf("strip=block_comments should keep code, got:\n%s", out)
}
if !strings.Contains(out, "[stripped: block_comments]") {
t.Errorf("block_comments strip footer missing, got:\n%s", out)
}
}
func TestStripMultipleFlags(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `/* Copyright 2024. License: MIT */
package main
import "fmt"
func main() {
fmt.Println("hello")
}
`
f := writeFile(t, tmpDir, "main.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"license", "imports"},
})
if strings.Contains(out, "Copyright") {
t.Errorf("license not stripped, got:\n%s", out)
}
if strings.Contains(out, `"fmt"`) {
t.Errorf("imports not stripped, got:\n%s", out)
}
if !strings.Contains(out, "[stripped:") {
t.Errorf("strip footer missing, got:\n%s", out)
}
}
func TestStripNoRemovalProducesNoFooter(t *testing.T) {
// A file with no imports: strip=imports should not add footer
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\nfunc main() {}\n"
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"imports"},
})
if strings.Contains(out, "[stripped:") {
t.Errorf("should not have stripped footer when nothing removed, got:\n%s", out)
}
}
func TestStripImportsPython(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
pySrc := `import os
from sys import argv
def main():
print(argv[0])
`
f := writeFile(t, tmpDir, "test.py", pySrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"imports"},
})
if strings.Contains(out, "import os") {
t.Errorf("Python imports not stripped, got:\n%s", out)
}
if strings.Contains(out, "from sys") {
t.Errorf("Python from-import not stripped, got:\n%s", out)
}
if !strings.Contains(out, "def main") {
t.Errorf("Python function missing after strip, got:\n%s", out)
}
}
// ---- Feature 3: short etag (8 hex chars) ----
func TestEtagIs8Chars(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "test.go", "package main\n")
out := callRead(t, srv, map[string]interface{}{"path": f})
// Find "[etag: XXXXXXXX]"
idx := strings.Index(out, "[etag: ")
if idx < 0 {
t.Fatalf("no etag in output: %q", out)
}
rest := out[idx+7:]
end := strings.Index(rest, "]")
if end < 0 {
t.Fatalf("malformed etag in output: %q", out)
}
etagVal := rest[:end]
if len(etagVal) != 8 {
t.Errorf("etag should be 8 hex chars, got %d chars: %q", len(etagVal), etagVal)
}
// Validate hex
for _, c := range etagVal {
isDigit := c >= '0' && c <= '9'
isHexLower := c >= 'a' && c <= 'f'
if !isDigit && !isHexLower {
t.Errorf("etag contains non-hex char %q in %q", c, etagVal)
}
}
}
func TestEtagPreviousEtagShortCircuit(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "test.go", "package main\n")
// First read: get the etag
out1 := callRead(t, srv, map[string]interface{}{"path": f})
idx := strings.Index(out1, "[etag: ")
etag := out1[idx+7 : idx+7+8]
// Second read with same etag: should short-circuit
out2 := callRead(t, srv, map[string]interface{}{
"path": f,
"previous_etag": etag,
})
if !strings.Contains(out2, "[unchanged, etag:") {
t.Errorf("expected [unchanged, etag:] for same etag, got: %q", out2)
}
}
func TestEtagOldLongEtagStillWorks(t *testing.T) {
// Simulate old client sending 16-char etag: should still short-circuit via prefix match.
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "test.go", "package main\n")
// Get the 8-char etag
out1 := callRead(t, srv, map[string]interface{}{"path": f})
idx := strings.Index(out1, "[etag: ")
shortEtag := out1[idx+7 : idx+7+8]
// Construct a fake 16-char etag that starts with the short one
fakeOldEtag := shortEtag + "00000000"
out2 := callRead(t, srv, map[string]interface{}{
"path": f,
"previous_etag": fakeOldEtag,
})
if !strings.Contains(out2, "[unchanged, etag:") {
t.Errorf("old 16-char etag should still short-circuit, got: %q", out2)
}
}
func TestEtagDifferentFileChanges(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "test.go", "package main\n")
out1 := callRead(t, srv, map[string]interface{}{"path": f})
idx := strings.Index(out1, "[etag: ")
etag1 := out1[idx+7 : idx+7+8]
// Modify the file
if err := os.WriteFile(f, []byte("package main\n// changed\n"), 0600); err != nil {
t.Fatal(err)
}
// Read again with old etag: should NOT short-circuit
out2 := callRead(t, srv, map[string]interface{}{
"path": f,
"previous_etag": etag1,
})
if strings.Contains(out2, "[unchanged") {
t.Errorf("modified file should not return unchanged, got: %q", out2)
}
}
// ---- Feature 4: compact_line_numbers ----
func TestCompactLineNumbers(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
content := "line one\nline two\nline three\n"
f := writeFile(t, tmpDir, "test.txt", content)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"compact_line_numbers": true,
})
// Should have "1│" not " 1│ "
if !strings.Contains(out, "1│line one") {
t.Errorf("compact prefix not found, got:\n%s", out)
}
// Should NOT have padded format
if strings.Contains(out, " 1│ line one") {
t.Errorf("compact should not have padded prefix, got:\n%s", out)
}
}
func TestCompactLineNumbersDefaultAndOptOut(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
content := "line one\nline two\n"
f := writeFile(t, tmpDir, "test.txt", content)
// Default is now the compact prefix (token-saving): "1│line one".
def := callRead(t, srv, map[string]interface{}{"path": f})
if !strings.Contains(def, "1│line one") || strings.Contains(def, " 1│ line one") {
t.Errorf("default should use compact prefix, got:\n%s", def)
}
// Explicit opt-out restores the padded alignment.
padded := callRead(t, srv, map[string]interface{}{"path": f, "compact_line_numbers": false})
if !strings.Contains(padded, " 1│ line one") {
t.Errorf("compact_line_numbers=false should restore padded format, got:\n%s", padded)
}
}
func TestCompactLineNumbersWithInterval(t *testing.T) {
// compact_line_numbers + line_number_interval should work together
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
var sb strings.Builder
for i := 1; i <= 10; i++ {
sb.WriteString("line\n")
}
f := writeFile(t, tmpDir, "test.txt", sb.String())
out := callRead(t, srv, map[string]interface{}{
"path": f,
"compact_line_numbers": true,
"line_number_interval": 5,
})
// Line 5 should have number prefix
if !strings.Contains(out, "5│line") {
t.Errorf("compact+interval: line 5 should have number, got:\n%s", out)
}
// Line 1 (first) should have number
if !strings.Contains(out, "1│line") {
t.Errorf("compact+interval: line 1 should have number, got:\n%s", out)
}
// Line 10 (last) should have number
if !strings.Contains(out, "10│line") {
t.Errorf("compact+interval: line 10 should have number, got:\n%s", out)
}
// Non-interval line should have bare │ prefix (no number)
if !strings.Contains(out, "│line") {
t.Errorf("compact+interval: non-interval lines should have bare │, got:\n%s", out)
}
}
func TestCompactLineNumbersWithNoLineNumbers(t *testing.T) {
// compact_line_numbers + no_line_numbers: no_line_numbers wins
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "test.txt", "line one\n")
out := callRead(t, srv, map[string]interface{}{
"path": f,
"compact_line_numbers": true,
"no_line_numbers": true,
})
// Should have NO prefix at all
if strings.Contains(out, "│") {
t.Errorf("no_line_numbers should suppress all prefixes, got:\n%s", out)
}
}
// ---- Backward compatibility: existing behavior unchanged ----
func TestDefaultBehaviorUnchanged(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `package main
func Hello() {
println("hello")
}
`
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{"path": f})
// All lines present
if !strings.Contains(out, `println("hello")`) {
t.Errorf("full mode missing body, got:\n%s", out)
}
// Default line numbers use the compact prefix.
if !strings.Contains(out, "1│package main") {
t.Errorf("default should have compact line numbers, got:\n%s", out)
}
// etag present
if !strings.Contains(out, "[etag:") {
t.Errorf("missing etag footer, got:\n%s", out)
}
}
func TestSymbolsOnlyFlagStillWorks(t *testing.T) {
// Old symbols_only=true + include_ast=true should still work
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\nfunc Hello() { println(1) }\n"
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"include_ast": true,
"symbols_only": true,
})
if strings.Contains(out, "println") {
t.Errorf("symbols_only should suppress body, got:\n%s", out)
}
if !strings.Contains(out, "[etag:") {
t.Errorf("symbols_only missing etag, got:\n%s", out)
}
}
// ---- Feature: resource_link for big reads ----
func TestResourceLinkThresholdTrip(t *testing.T) {
// When result bytes > threshold, handleFileRead returns a ResourceLink content block.
tmpDir := t.TempDir()
// Low threshold (10 bytes) guarantees even a tiny file trips it.
srv := newTestServerWithThreshold(t, tmpDir, 10)
f := writeFile(t, tmpDir, "big.txt", strings.Repeat("x", 200))
result := callReadResult(t, srv, map[string]interface{}{"path": f})
if len(result.Content) == 0 {
t.Fatal("expected content, got none")
}
link, ok := result.Content[0].(mcp.ResourceLink)
if !ok {
t.Fatalf("expected ResourceLink content, got %T", result.Content[0])
}
if !strings.HasPrefix(link.URI, "filepuff://read/") {
t.Errorf("ResourceLink URI should start with filepuff://read/, got: %q", link.URI)
}
if link.Name != f {
t.Errorf("ResourceLink Name should be file path %q, got %q", f, link.Name)
}
if !strings.Contains(link.Description, "etag=") {
t.Errorf("ResourceLink Description should contain etag=, got %q", link.Description)
}
if !strings.Contains(link.Description, "size=") {
t.Errorf("ResourceLink Description should contain size=, got %q", link.Description)
}
if !strings.Contains(link.Description, "lines=") {
t.Errorf("ResourceLink Description should contain lines=, got %q", link.Description)
}
}
func TestResourceLinkForceInlineBypass(t *testing.T) {
// force_inline=true must always return TextContent regardless of threshold.
tmpDir := t.TempDir()
srv := newTestServerWithThreshold(t, tmpDir, 1) // threshold = 1 byte, always trips
f := writeFile(t, tmpDir, "test.txt", "hello world")
result := callReadResult(t, srv, map[string]interface{}{
"path": f,
"force_inline": true,
})
if len(result.Content) == 0 {
t.Fatal("expected content, got none")
}
tc, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatalf("force_inline=true should return TextContent, got %T", result.Content[0])
}
if !strings.Contains(tc.Text, "hello world") {
t.Errorf("force_inline result should contain file content, got: %q", tc.Text)
}
}
func TestResourceLinkMaxInlineBytesOverride(t *testing.T) {
// max_inline_bytes overrides server threshold per-call.
tmpDir := t.TempDir()
// Server threshold = 1 byte (always trips), but max_inline_bytes allows bigger.
srv := newTestServerWithThreshold(t, tmpDir, 1)
content := strings.Repeat("a", 50) // 50 bytes
f := writeFile(t, tmpDir, "test.txt", content)
// max_inline_bytes=100 > 50 bytes result → should inline
result := callReadResult(t, srv, map[string]interface{}{
"path": f,
"max_inline_bytes": 100,
})
if len(result.Content) == 0 {
t.Fatal("expected content, got none")
}
_, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatalf("max_inline_bytes=100 with 50-byte file should return TextContent, got %T", result.Content[0])
}
}
func TestResourceLinkStaleEtagRejection(t *testing.T) {
// handleReadResource should reject fetch when file has changed since link was issued.
tmpDir := t.TempDir()
srv := newTestServerWithThreshold(t, tmpDir, 1) // always trips
f := writeFile(t, tmpDir, "stale.txt", "original content")
// Get a ResourceLink — captures etag of "original content"
result := callReadResult(t, srv, map[string]interface{}{"path": f})
link, ok := result.Content[0].(mcp.ResourceLink)
if !ok {
t.Fatalf("expected ResourceLink, got %T", result.Content[0])
}
// URI contains ?etag=<hash-of-original>
if !strings.Contains(link.URI, "?etag=") {
t.Fatalf("expected etag in URI, got %q", link.URI)
}
// Overwrite the file with new content.
if err := os.WriteFile(f, []byte("modified content — different"), 0600); err != nil {
t.Fatal(err)
}
// Fetch the resource using the stale URI — should error.
req := mcp.ReadResourceRequest{}
req.Params.URI = link.URI
_, err := srv.handleReadResource(req)
if err == nil {
t.Fatal("expected error for stale etag, got nil")
}
if !strings.Contains(err.Error(), "file changed") {
t.Errorf("error should mention 'file changed', got: %v", err)
}
}
func TestResourceLinkBelowThresholdInlines(t *testing.T) {
// When result is small (below threshold), always inline regardless of threshold setting.
tmpDir := t.TempDir()
// Large threshold — small file should be inlined.
srv := newTestServerWithThreshold(t, tmpDir, 64*1024)
f := writeFile(t, tmpDir, "small.txt", "tiny")
result := callReadResult(t, srv, map[string]interface{}{"path": f})
if len(result.Content) == 0 {
t.Fatal("expected content, got none")
}
_, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatalf("small file should return TextContent, got %T", result.Content[0])
}
}
func TestResourceLinkThresholdZeroDisabled(t *testing.T) {
// threshold=0 disables the feature entirely — always inline.
tmpDir := t.TempDir()
srv := newTestServerWithThreshold(t, tmpDir, 0)
f := writeFile(t, tmpDir, "test.txt", strings.Repeat("z", 10000))
result := callReadResult(t, srv, map[string]interface{}{"path": f})
if len(result.Content) == 0 {
t.Fatal("expected content")
}
_, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatalf("threshold=0 should always inline, got %T", result.Content[0])
}
}
func TestHandleReadResource_ValidFetch(t *testing.T) {
// handleReadResource fetches file content when etag matches.
tmpDir := t.TempDir()
srv := newTestServerWithThreshold(t, tmpDir, 1)
f := writeFile(t, tmpDir, "fetch.txt", "fetch me please")
// Trigger a ResourceLink to get a valid URI with correct etag.
toolResult := callReadResult(t, srv, map[string]interface{}{"path": f})
link, ok := toolResult.Content[0].(mcp.ResourceLink)
if !ok {
t.Fatalf("expected ResourceLink, got %T", toolResult.Content[0])
}
req := mcp.ReadResourceRequest{}
req.Params.URI = link.URI
contents, err := srv.handleReadResource(req)
if err != nil {
t.Fatalf("handleReadResource error: %v", err)
}
if len(contents) == 0 {
t.Fatal("expected resource contents, got none")
}
tc, ok := contents[0].(mcp.TextResourceContents)
if !ok {
t.Fatalf("expected TextResourceContents, got %T", contents[0])
}
if !strings.Contains(tc.Text, "fetch me please") {
t.Errorf("resource contents should include file content, got: %q", tc.Text)
}
}
func TestResourceLinkMIMEType(t *testing.T) {
// Verify MIME types for common extensions.
tmpDir := t.TempDir()
srv := newTestServerWithThreshold(t, tmpDir, 1)
cases := []struct {
name string
content string
wantMIME string
}{
{"test.go", "package main\n", "text/x-go"},
{"test.py", "# py\n", "text/x-python"},
{"test.ts", "// ts\n", "text/typescript"},
{"test.json", "{}\n", "application/json"},
{"test.md", "# hi\n", "text/markdown"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := writeFile(t, tmpDir, c.name, c.content)
result := callReadResult(t, srv, map[string]interface{}{"path": f})
link, ok := result.Content[0].(mcp.ResourceLink)
if !ok {
t.Fatalf("expected ResourceLink for %s, got %T", c.name, result.Content[0])
}
if link.MIMEType != c.wantMIME {
t.Errorf("%s: MIMEType = %q, want %q", c.name, link.MIMEType, c.wantMIME)
}
})
}
}
+324
View File
@@ -0,0 +1,324 @@
// Package server implements the MCP server for file operations.
package server
import (
"context"
"fmt"
"os"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
"github.com/mark3labs/mcp-go/mcp"
)
// handleLSPQuery is the unified dispatcher for all LSP operations.
// action must be one of: "hover", "definition", "references".
func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
action, err := request.RequireString("action")
if err != nil {
return mcp.NewToolResultError("action is required (hover | definition | references)"), nil
}
file, err := request.RequireString("file")
if err != nil {
return mcp.NewToolResultError("file is required"), nil
}
if !s.cfg.IsPathAllowed(file) {
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)
switch action {
case "hover":
if _, ok := request.GetArguments()["include_declaration"]; ok {
return mcp.NewToolResultError("include_declaration is only valid for action=references"), nil
}
if _, ok := request.GetArguments()["compact"]; ok {
return mcp.NewToolResultError("compact is only valid for action=references"), nil
}
return s.lspHover(ctx, file, line, col, verbose)
case "definition":
if _, ok := request.GetArguments()["include_declaration"]; ok {
return mcp.NewToolResultError("include_declaration is only valid for action=references"), nil
}
if _, ok := request.GetArguments()["compact"]; ok {
return mcp.NewToolResultError("compact is only valid for action=references"), nil
}
return s.lspDefinition(ctx, file, line, col, verbose)
case "references":
includeDecl := request.GetBool("include_declaration", true)
// compact: explicit call-time > session compact_refs pref > true.
// Compact (one line per file) is the default — it preserves every
// line:col pair while cutting ~one line per reference for the LLM consumer.
var prefsCompact *bool
if sp := s.sessionPrefs.Load(); sp != nil {
prefsCompact = sp.CompactRefs
}
compact := effectiveBool(request, "compact", prefsCompact, true)
return s.lspReferences(ctx, file, line, col, includeDecl, compact, verbose)
default:
return mcp.NewToolResultError(fmt.Sprintf("unknown action %q: must be hover | definition | references", action)), nil
}
}
// 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.
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)
if err != nil {
return s.handleSymbolAtFallback(ctx, file, line, col, verbose)
}
if hover == nil {
return mcp.NewToolResultText("No symbol information available at this position."), nil
}
var output strings.Builder
if verbose {
output.WriteString("**Symbol Information**\n\n")
}
output.WriteString(hover.Contents.Value)
return mcp.NewToolResultText(output.String()), nil
}
// handleSymbolAtFallback provides AST-based symbol info when LSP is unavailable.
func (s *Server) handleSymbolAtFallback(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
content, err := os.ReadFile(file)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read file: %s", errors.SanitizeError(err))), nil
}
result, err := s.parser.Parse(ctx, file, content)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to parse file: %s", errors.SanitizeError(err))), nil
}
node := parser.FindNodeAtPosition(result.Tree, line, col)
if node == nil {
return mcp.NewToolResultText("No symbol at this position."), nil
}
var output strings.Builder
if verbose {
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")
}
fmt.Fprintf(&output, "Node type: `%s`\n", node.Type())
fmt.Fprintf(&output, "Text: `%s`\n", parser.GetNodeText(node, content))
return mcp.NewToolResultText(output.String()), nil
}
// lspDefinition finds the definition of the symbol at the given position.
func (s *Server) lspDefinition(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
locations, err := s.lspManager.Definition(ctx, file, line, col)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("definition lookup failed: %s", errors.SanitizeError(err))), nil
}
if len(locations) == 0 {
return mcp.NewToolResultText("No definition found."), nil
}
var output strings.Builder
if verbose {
fmt.Fprintf(&output, "Found %d definition(s):\n\n", len(locations))
}
for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI)
fmt.Fprintf(&output, "**%s:%d:%d**\n", s.relPath(filePath), loc.Range.Start.Line+1, loc.Range.Start.Character+1)
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
if preview != "" {
output.WriteString("```\n")
output.WriteString(preview)
output.WriteString("```\n")
}
output.WriteString("\n")
}
return mcp.NewToolResultText(output.String()), nil
}
// lspReferences finds all references to the symbol at the given position.
func (s *Server) lspReferences(ctx context.Context, file string, line, col int, includeDecl, compact, verbose bool) (*mcp.CallToolResult, error) {
locations, err := s.lspManager.References(ctx, file, line, col, includeDecl)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("references lookup failed: %s", errors.SanitizeError(err))), nil
}
if len(locations) == 0 {
return mcp.NewToolResultText("No references found."), nil
}
// Group by file, preserving encounter order.
fileGroups := make(map[string][]lsp.Location)
fileOrder := make([]string, 0)
for _, loc := range locations {
// Relativize for display; references are not re-read so the absolute
// path isn't needed downstream.
filePath := s.relPath(lsp.URIToFile(loc.URI))
if _, seen := fileGroups[filePath]; !seen {
fileOrder = append(fileOrder, filePath)
}
fileGroups[filePath] = append(fileGroups[filePath], loc)
}
return mcp.NewToolResultText(formatReferences(fileGroups, fileOrder, len(locations), compact, verbose)), nil
}
// formatReferences formats grouped reference locations as a string.
// compact=false: verbose multi-line format with L{line}:{col} per entry.
// compact=true: one line per file — file:[line:col, ...] (N), with same-line
// columns collapsed to line:{col1,col2,...}.
func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string, total int, compact bool, verbose bool) string {
var output strings.Builder
if verbose {
fmt.Fprintf(&output, "Found %d reference(s):\n\n", total)
}
for _, filePath := range fileOrder {
locs := fileGroups[filePath]
if compact {
output.WriteString(formatReferencesCompact(filePath, locs))
} else {
fmt.Fprintf(&output, "**%s** (%d)\n", filePath, len(locs))
for _, loc := range locs {
fmt.Fprintf(&output, " L%d:%d\n", loc.Range.Start.Line+1, loc.Range.Start.Character+1)
}
output.WriteString("\n")
}
}
return output.String()
}
// formatReferencesCompact formats one file's references as a single compact line.
// Same-line references are collapsed: 12:{5,8} instead of 12:5, 12:8.
func formatReferencesCompact(filePath string, locs []lsp.Location) string {
// Build ordered line->col map preserving encounter order per line.
type lineEntry struct {
lineNum int
cols []int
}
lineMap := make(map[int]*lineEntry)
lineOrder := make([]int, 0, len(locs))
for _, loc := range locs {
ln := loc.Range.Start.Line + 1
col := loc.Range.Start.Character + 1
if e, ok := lineMap[ln]; ok {
e.cols = append(e.cols, col)
} else {
lineMap[ln] = &lineEntry{lineNum: ln, cols: []int{col}}
lineOrder = append(lineOrder, ln)
}
}
// Build the bracket contents.
parts := make([]string, 0, len(lineOrder))
for _, ln := range lineOrder {
e := lineMap[ln]
if len(e.cols) == 1 {
parts = append(parts, fmt.Sprintf("%d:%d", ln, e.cols[0]))
} else {
colStrs := make([]string, len(e.cols))
for i, c := range e.cols {
colStrs[i] = fmt.Sprintf("%d", c)
}
parts = append(parts, fmt.Sprintf("%d:{%s}", ln, strings.Join(colStrs, ",")))
}
}
return fmt.Sprintf("%s:[%s] (%d)\n", filePath, strings.Join(parts, ", "), len(locs))
}
// readFilePreview reads a few lines from a file around the given line.
// It validates that the file path is within the allowed workspace before reading.
func (s *Server) readFilePreview(file string, line, contextLines int) string {
if !s.cfg.IsPathAllowed(file) {
s.logger.Warn("readFilePreview: path not allowed", "path", file)
return ""
}
content, err := os.ReadFile(file)
if err != nil {
return ""
}
lines := splitLines(string(content))
startLine := max(1, line-contextLines)
endLine := min(line+contextLines, len(lines))
var preview strings.Builder
for i := startLine - 1; i < endLine && i < len(lines); i++ {
lineText := lines[i]
if len(lineText) > PreviewLineMaxLength {
lineText = lineText[:PreviewLineMaxLength] + "..."
}
prefix := " "
if i+1 == line {
prefix = "> "
}
fmt.Fprintf(&preview, "%s%4d: %s\n", prefix, i+1, lineText)
}
return preview.String()
}
+244
View File
@@ -0,0 +1,244 @@
package server
import (
"context"
"strings"
"testing"
"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.
func makeLocation(file string, line, col int) lsp.Location {
return lsp.Location{
URI: "file://" + file,
Range: lsp.Range{
Start: lsp.Position{Line: line - 1, Character: col - 1},
End: lsp.Position{Line: line - 1, Character: col - 1},
},
}
}
// groupLocations is a helper that mirrors the grouping logic in lspReferences.
func groupLocations(locations []lsp.Location) (map[string][]lsp.Location, []string) {
fileGroups := make(map[string][]lsp.Location)
fileOrder := make([]string, 0)
for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI)
if _, seen := fileGroups[filePath]; !seen {
fileOrder = append(fileOrder, filePath)
}
fileGroups[filePath] = append(fileGroups[filePath], loc)
}
return fileGroups, fileOrder
}
// TestFormatReferencesVerbose verifies the default (non-compact) format is unchanged.
func TestFormatReferencesVerbose(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/foo.go", 12, 5),
makeLocation("/a/foo.go", 13, 8),
makeLocation("/a/bar.go", 15, 1),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), false, true)
if !strings.Contains(out, "Found 3 reference(s):") {
t.Errorf("missing header, got:\n%s", out)
}
if !strings.Contains(out, "**") {
t.Errorf("verbose format should use **file** markers, got:\n%s", out)
}
if !strings.Contains(out, "L12:5") {
t.Errorf("missing L12:5 in verbose output, got:\n%s", out)
}
if !strings.Contains(out, "L13:8") {
t.Errorf("missing L13:8 in verbose output, got:\n%s", out)
}
if !strings.Contains(out, "L15:1") {
t.Errorf("missing L15:1 in verbose output, got:\n%s", out)
}
}
// TestFormatReferencesCompactBasic verifies compact output for distinct lines.
func TestFormatReferencesCompactBasic(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/foo.go", 12, 5),
makeLocation("/a/foo.go", 13, 8),
makeLocation("/a/foo.go", 15, 12),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), true, true)
if !strings.Contains(out, "Found 3 reference(s):") {
t.Errorf("missing header, got:\n%s", out)
}
// Should contain "foo.go:[12:5, 13:8, 15:12] (3)"
if !strings.Contains(out, "12:5") {
t.Errorf("missing 12:5 in compact output, got:\n%s", out)
}
if !strings.Contains(out, "13:8") {
t.Errorf("missing 13:8 in compact output, got:\n%s", out)
}
if !strings.Contains(out, "15:12") {
t.Errorf("missing 15:12 in compact output, got:\n%s", out)
}
if !strings.Contains(out, "(3)") {
t.Errorf("missing (3) count, got:\n%s", out)
}
// Compact format must NOT use ** markers
if strings.Contains(out, "**") {
t.Errorf("compact format must not use ** markers, got:\n%s", out)
}
// Must not have L prefix
if strings.Contains(out, "L12") {
t.Errorf("compact format must not have L prefix, got:\n%s", out)
}
}
// TestFormatReferencesCompactSameLineCollapse verifies same-line columns collapse.
func TestFormatReferencesCompactSameLineCollapse(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/foo.go", 12, 5),
makeLocation("/a/foo.go", 12, 8),
makeLocation("/a/foo.go", 15, 3),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), true, false)
// Line 12 has two refs → should be collapsed: 12:{5,8}
if !strings.Contains(out, "12:{5,8}") {
t.Errorf("same-line refs should collapse to 12:{5,8}, got:\n%s", out)
}
// Line 15 is single → 15:3
if !strings.Contains(out, "15:3") {
t.Errorf("single ref on line 15 should be 15:3, got:\n%s", out)
}
}
// TestFormatReferencesCompactMultiFile verifies compact output across multiple files.
func TestFormatReferencesCompactMultiFile(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/foo.go", 5, 1),
makeLocation("/b/bar.go", 10, 3),
makeLocation("/b/bar.go", 10, 7),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), true, true)
if !strings.Contains(out, "Found 3 reference(s):") {
t.Errorf("missing header, got:\n%s", out)
}
// foo.go: one ref
if !strings.Contains(out, "5:1") {
t.Errorf("missing 5:1 for foo.go, got:\n%s", out)
}
// bar.go: two refs on same line → collapsed
if !strings.Contains(out, "10:{3,7}") {
t.Errorf("missing 10:{3,7} collapse for bar.go, got:\n%s", out)
}
}
// TestFormatReferencesCompactSingleRef verifies single-reference compact output.
func TestFormatReferencesCompactSingleRef(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/only.go", 7, 2),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), true, false)
if !strings.Contains(out, "7:2") {
t.Errorf("missing 7:2, got:\n%s", out)
}
if !strings.Contains(out, "(1)") {
t.Errorf("missing (1), got:\n%s", out)
}
}
// TestFormatReferencesCompactNoLPrefix verifies the L prefix is absent in compact mode.
func TestFormatReferencesCompactNoLPrefix(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/x.go", 3, 4),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), true, false)
if strings.Contains(out, "L3") {
t.Errorf("compact output must not contain L prefix, got:\n%s", out)
}
}
// TestFormatReferencesVerboseNoChange verifies compact=false preserves old L-prefix format.
func TestFormatReferencesVerbosePreservesLPrefix(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/x.go", 3, 4),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), false, true)
if !strings.Contains(out, "L3:4") {
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))
}
})
}
+204
View File
@@ -0,0 +1,204 @@
// Package server implements the MCP server for file operations.
package server
// helpFileRead is the full flag documentation and examples for the file_read tool,
// served at filepuff://help/file_read.
const helpFileRead = "# file_read — flags and examples\n\n" +
"## Token-saving features\n\n" +
"| Flag | Effect |\n" +
"|------|--------|\n" +
"| `previous_etag` | Skip re-reading unchanged files. Returns `[unchanged, etag: ...]` if file is unchanged. |\n" +
"| `symbol_name` | Read only a named function/struct/class — eliminates an ast_query round-trip. |\n" +
"| `symbols_only=true` | Return only symbol list (~95% fewer tokens). Requires `include_ast=true`. Alias: `mode='symbols_only'`. |\n" +
"| `mode` | `full` (default) \\| `skeleton` (signatures + `{ ... }` stubs, bodies elided) \\| `symbols_only` |\n" +
"| `strip` | Remove content classes before line-numbering: `imports`, `license`, `block_comments`. Emits `[stripped: ...]` footer. |\n" +
"| `no_line_numbers=true` | Omit the ` 12│ ` line-number prefix (~10% savings). `line_number_interval=0` has the same effect. |\n" +
"| `line_number_interval=N` | Print line numbers only every N lines. |\n" +
"| `compact_line_numbers=true` | Use compact `12│` prefix instead of ` 12│ ` (no padding, no trailing space). |\n" +
"| `collapse_blank_lines=true` | Collapse consecutive blank lines to one. |\n" +
"| `max_lines=N` | Truncate output with omitted count notice. Applied after `line_start`/`line_end`. |\n" +
"| `paths=[...]` | Read multiple files in one call. Each file gets a `--- path ---` header. |\n\n" +
"All responses include `[etag: hex]` footer (8 hex chars) for use as `previous_etag` in subsequent reads.\n\n" +
"## Examples\n\n" +
"```json\n" +
"// Full file\n" +
`{"path": "main.go"}` + "\n\n" +
"// Etag check — returns unchanged notice if file hasn't changed\n" +
`{"path": "main.go", "previous_etag": "a3f9c2b1"}` + "\n\n" +
"// Read only one named symbol\n" +
`{"path": "server.go", "symbol_name": "handleFileRead"}` + "\n\n" +
"// Skeleton mode — signatures only, bodies elided\n" +
`{"path": "server.go", "mode": "skeleton"}` + "\n\n" +
"// Strip imports and license header\n" +
`{"path": "main.go", "strip": ["imports", "license"]}` + "\n\n" +
"// Batch read multiple files\n" +
`{"paths": ["a.go", "b.go"]}` + "\n\n" +
"// Specific line range\n" +
`{"path": "main.go", "line_start": 10, "line_end": 50}` + "\n" +
"```\n"
// helpFileSearch is the full flag documentation and examples for the file_search tool,
// served at filepuff://help/file_search.
const helpFileSearch = "# file_search — flags and examples\n\n" +
"## Output format\n\n" +
"Matches grouped by file. Each file section has matching lines prefixed by `L{line}│` and context lines prefixed by ` │`. Zero matches: `No matches found.`\n\n" +
"## Flags\n\n" +
"| Flag | Effect |\n" +
"|------|--------|\n" +
"| `verbose=true` | Emit `Found N matches in M files:` preamble (v1 behaviour). Default: false. |\n" +
"| `cluster=true` | Coalesce consecutive match lines into ranges (`L12-14│ text`). Drops context lines for density. |\n" +
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
"| `max_results` | Page size for pagination. Re-run with `cursor` to get next page. |\n" +
"| `context_lines` | Number of context lines around matches (default: 2). |\n" +
"| `ignore_case` | Case-insensitive search. |\n" +
"| `regex` | Treat pattern as regex (default: true). |\n" +
"| `file_types` | Restrict to file extensions, e.g. `[\"go\", \"ts\"]`. |\n" +
"| `paths` | Paths to search in (defaults to workspace root). |\n\n" +
"## Examples\n\n" +
"```json\n" +
"// Search for error-returning functions in Go files\n" +
`{"pattern": "func.*Error", "file_types": ["go"], "max_results": 20}` + "\n\n" +
"// Case-insensitive literal search\n" +
`{"pattern": "TODO", "ignore_case": true}` + "\n\n" +
"// Paginated search — fetch next page\n" +
`{"pattern": "import", "max_results": 50, "cursor": "<token from previous response>"}` + "\n\n" +
"// Clustered — dense view of many matches\n" +
`{"pattern": "return err", "file_types": ["go"], "cluster": true}` + "\n" +
"```\n"
// helpASTQuery is the full flag documentation and examples for the ast_query tool,
// served at filepuff://help/ast_query.
const helpASTQuery = "# ast_query — flags and examples\n\n" +
"## Output format\n\n" +
"Entries in format `**file:line** (node_type)` with code blocks and captured variables (`$NAME=value`). Zero matches: `No matches found.`\n\n" +
"## Flags\n\n" +
"| Flag | Effect |\n" +
"|------|--------|\n" +
"| `verbose=true` | Emit `Found N match(es):` preamble (v1 behaviour). Default: false. |\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" +
"| `max_results` | Page size (default: 100). |\n" +
"| `name_exact` | Exact symbol name to match. |\n" +
"| `name_matches` | Regex pattern to filter by name. |\n" +
"| `kind_in` | Node types to match (e.g. `function_declaration`, `class_declaration`). |\n" +
"| `paths` | Paths to search in (defaults to workspace root). |\n\n" +
"## Pattern placeholders\n\n" +
"| Placeholder | Meaning |\n" +
"|-------------|----------|\n" +
"| `$NAME` | Matches a single node, captures as `$NAME` |\n" +
"| `$$$ARGS` | Matches zero or more nodes (variadic capture) |\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" +
"```json\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" +
"// Python classes\n" +
`{"pattern": "class $NAME: $$$BODY", "language": "python"}` + "\n\n" +
"// Specific named function\n" +
`{"pattern": "func $NAME($$$ARGS)", "language": "go", "name_exact": "NewServer"}` + "\n\n" +
"// Compact output — one line per match\n" +
`{"pattern": "func $NAME($$$ARGS) error", "language": "go", "format": "compact"}` + "\n" +
"```\n"
// helpLSPQuery is the full flag documentation and examples for the lsp_query tool,
// served at filepuff://help/lsp_query.
const helpLSPQuery = "# lsp_query — flags and examples\n\n" +
"## Actions\n\n" +
"### hover\n" +
"Returns type/doc from LSP, falls back to AST node info. `verbose=true` adds `**Symbol Information**` header.\n\n" +
"### definition\n" +
"Returns `file:line:col` + 3-line code preview for each definition. `verbose=true` adds `Found N definition(s):` header.\n\n" +
"### references\n" +
"Returns references grouped by file. Flags:\n" +
"- `include_declaration` (default true) — include the declaration itself\n" +
"- `compact=true` — collapse to one line per file\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" +
"## 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" +
"```json\n" +
"// Hover — type/doc at position\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" +
`{"action": "definition", "file": "handler.go", "line": 23, "column": 10}` + "\n\n" +
"// References — all usages\n" +
`{"action": "references", "file": "types.go", "line": 5, "column": 6}` + "\n\n" +
"// References — compact (one line per file)\n" +
`{"action": "references", "file": "types.go", "line": 5, "column": 6, "compact": true}` + "\n" +
"```\n"
// helpEditApply is the full flag documentation and examples for the edit_apply tool,
// served at filepuff://help/edit_apply.
const helpEditApply = "# edit_apply — flags and examples\n\n" +
"## Response format (`response` flag)\n\n" +
"| Value | Output |\n" +
"|-------|--------|\n" +
"| `count` (default) | `+3 -1` added/removed line counts only |\n" +
"| `diff` | Full unified diff of changes made |\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" +
"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" +
"### AST-mode selectors (code files)\n" +
"- `selector_kind` — AST node type (e.g. `function_declaration`, `class_declaration`)\n" +
"- `selector_name` — symbol name to match\n\n" +
"### Text-mode selectors (all files)\n" +
"- `selector_text` — exact text to match (must be unique, or use `selector_index`)\n" +
"- `selector_pattern` — regex pattern to match\n" +
"- `selector_line` / `selector_line_end` — line range\n\n" +
"### Shared\n" +
"- `selector_index` — index of match when multiple exist (default: 0)\n\n" +
"## Examples\n\n" +
"```json\n" +
"// AST mode — replace a named function\n" +
"{\n" +
` "file": "main.go",` + "\n" +
` "operation": "replace",` + "\n" +
` "selector_kind": "function_declaration",` + "\n" +
` "selector_name": "Hello",` + "\n" +
` "new_content": "func Hello() {\n\treturn\n}"` + "\n" +
"}\n\n" +
"// Text mode — replace a markdown header\n" +
"{\n" +
` "file": "README.md",` + "\n" +
` "operation": "replace",` + "\n" +
` "selector_text": "## Old Header",` + "\n" +
` "new_content": "## New Header"` + "\n" +
"}\n\n" +
"// Line range replacement\n" +
"{\n" +
` "file": "config.yaml",` + "\n" +
` "operation": "replace",` + "\n" +
` "selector_line": 5,` + "\n" +
` "selector_line_end": 10,` + "\n" +
` "new_content": "key: value"` + "\n" +
"}\n\n" +
"// Request full diff in response\n" +
"{\n" +
` "file": "main.go",` + "\n" +
` "operation": "replace",` + "\n" +
` "selector_name": "Hello",` + "\n" +
` "new_content": "func Hello() {}",` + "\n" +
` "response": "diff"` + "\n" +
"}\n" +
"```\n"
+474
View File
@@ -0,0 +1,474 @@
package server
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/mark3labs/mcp-go/mcp"
)
// TestMCPProtocolEndToEnd tests the complete MCP protocol communication flow.
func TestMCPProtocolEndToEnd(t *testing.T) {
tmpDir := t.TempDir()
// Create test files
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func Hello() string {
return "hello"
}
`
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: config.DefaultMaxFileSize,
MaxParseSize: config.DefaultMaxParseSize,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
// Test: File read (ping removed — Change 3)
t.Run("file_read", func(t *testing.T) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"path": testFile,
}
result, err := srv.handleFileRead(ctx, req)
if err != nil {
t.Errorf("handleFileRead() error = %v", err)
}
if result == nil {
t.Fatal("handleFileRead() returned nil")
return
}
if len(result.Content) == 0 {
t.Fatal("handleFileRead() returned empty content")
}
})
// Test 3: AST query
t.Run("ast_query", func(t *testing.T) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
}
result, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Errorf("handleASTQuery() error = %v", err)
}
if result == nil {
t.Fatal("handleASTQuery() returned nil")
return
}
})
// Test 4: Edit workflow
t.Run("edit_workflow", func(t *testing.T) {
// Apply edit directly (preview removed to avoid confusing LLMs)
applyReq := mcp.CallToolRequest{}
applyReq.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Hello",
"new_content": "func Hello() string {\n\treturn \"goodbye\"\n}",
}
applyResult, err := srv.handleEditApply(ctx, applyReq)
if err != nil {
t.Errorf("handleEditApply() error = %v", err)
}
if applyResult == nil {
t.Fatal("handleEditApply() returned nil")
return
}
// Verify file changed after apply
modifiedContent, _ := os.ReadFile(testFile)
if string(modifiedContent) == content {
t.Error("apply should modify file")
}
})
}
// TestMCPToolDiscovery tests that all expected tools are registered.
func TestMCPToolDiscovery(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
// Note: The MCP server doesn't expose a method to list tools directly,
// but we can verify the server was created successfully
if srv.mcp == nil {
t.Fatal("MCP server not initialized")
}
// Ping tool removed (Change 3 — MCP protocol has own liveness check).
// Tools verified via integration tests in TestIntegrationFileOperations.
}
// TestMCPErrorResponses tests error handling following MCP spec.
func TestMCPErrorResponses(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: 1024, // Small size to trigger errors
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
tests := []struct {
name string
handler func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)
setupReq func() mcp.CallToolRequest
expectError bool
}{
{
name: "file_read_missing_path",
handler: srv.handleFileRead,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{}
return req
},
expectError: true,
},
{
name: "file_read_nonexistent",
handler: srv.handleFileRead,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"path": filepath.Join(tmpDir, "nonexistent.txt"),
}
return req
},
expectError: true,
},
{
name: "file_read_outside_workspace",
handler: srv.handleFileRead,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"path": "/etc/passwd",
}
return req
},
expectError: true,
},
{
name: "ast_query_missing_pattern",
handler: srv.handleASTQuery,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"language": "go",
}
return req
},
expectError: true,
},
{
name: "ast_query_missing_language",
handler: srv.handleASTQuery,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
}
return req
},
expectError: true,
},
{
name: "ast_query_unsupported_language",
handler: srv.handleASTQuery,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "cobol",
}
return req
},
expectError: true,
},
{
name: "edit_missing_file",
handler: srv.handleEditApply,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"operation": "replace",
}
return req
},
expectError: true,
},
{
name: "edit_missing_operation",
handler: srv.handleEditApply,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"file": filepath.Join(tmpDir, "test.go"),
}
return req
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request := tt.setupReq()
result, err := tt.handler(ctx, request)
// Check for error - MCP tools return errors as nil error with error in result content
hasError := err != nil || (result != nil && len(result.Content) > 0)
if tt.expectError && !hasError {
t.Errorf("expected error but got none")
}
// Note: We don't check for unexpected success because some operations
// might legitimately return empty results
})
}
}
// TestMCPRequestResponseFlow tests the complete request/response flow.
func TestMCPRequestResponseFlow(t *testing.T) {
tmpDir := t.TempDir()
// Create test file
testFile := filepath.Join(tmpDir, "flow.go")
content := `package main
func Add(a, b int) int {
return a + b
}
`
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: config.DefaultMaxFileSize,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Test sequential operations
t.Run("sequential_operations", func(t *testing.T) {
// 1. Read file
readReq := mcp.CallToolRequest{}
readReq.Params.Arguments = map[string]interface{}{
"path": testFile,
}
readResult, err := srv.handleFileRead(ctx, readReq)
if err != nil {
t.Fatalf("handleFileRead() error = %v", err)
}
if readResult == nil {
t.Fatal("handleFileRead() returned nil")
return
}
// 2. Query AST
queryReq := mcp.CallToolRequest{}
queryReq.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME($$$ARGS) int",
"language": "go",
"paths": []interface{}{tmpDir},
}
queryResult, err := srv.handleASTQuery(ctx, queryReq)
if err != nil {
t.Fatalf("handleASTQuery() error = %v", err)
}
if queryResult == nil {
t.Fatal("handleASTQuery() returned nil")
return
}
// 3. Edit (no preview)
editReq := mcp.CallToolRequest{}
editReq.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Add",
"new_content": "func Add(a, b int) int {\n\treturn a + b + 1\n}",
}
editResult, err := srv.handleEditApply(ctx, editReq)
if err != nil {
t.Fatalf("handleEditApply() error = %v", err)
}
if editResult == nil {
t.Fatal("handleEditApply() returned nil")
return
}
})
}
// TestMCPConcurrentRequests tests handling of concurrent requests.
func TestMCPConcurrentRequests(t *testing.T) {
tmpDir := t.TempDir()
// Create multiple test files
for i := 0; i < 5; i++ {
testFile := filepath.Join(tmpDir, "test"+string(rune(i+48))+".go")
content := `package main
func Test() {
println("test")
}
`
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: config.DefaultMaxFileSize,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
// Run multiple concurrent requests
const numRequests = 10
done := make(chan bool, numRequests)
errors := make(chan error, numRequests)
for i := 0; i < numRequests; i++ {
go func(index int) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
}
_, err := srv.handleASTQuery(ctx, req)
if err != nil {
errors <- err
}
done <- true
}(i)
}
// Wait for all requests to complete
for i := 0; i < numRequests; i++ {
<-done
}
// Check for errors
close(errors)
for err := range errors {
t.Errorf("concurrent request failed: %v", err)
}
}
// TestMCPContextCancellation tests handling of context cancellation.
func TestMCPContextCancellation(t *testing.T) {
tmpDir := t.TempDir()
// Create a large directory structure to ensure operation takes time
for i := 0; i < 10; i++ {
subdir := filepath.Join(tmpDir, "subdir"+string(rune(i+48)))
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
for j := 0; j < 10; j++ {
testFile := filepath.Join(subdir, "test"+string(rune(j+48))+".go")
content := `package main
func Test() {
println("test")
}
`
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
}
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: config.DefaultMaxFileSize,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
// Create a context with a very short timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
}
// This should either complete quickly or handle cancellation gracefully
_, err = srv.handleASTQuery(ctx, req)
// We don't check for specific error as it might complete before timeout
// The important thing is it doesn't panic or hang
if err != nil {
t.Logf("handleASTQuery with cancelled context: %v", err)
}
}
+156
View File
@@ -0,0 +1,156 @@
package server
import (
"context"
"fmt"
"net/url"
"os"
"strings"
xxhash "github.com/cespare/xxhash/v2"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
)
// helpResources maps a tool name to its help content constant.
var helpResources = map[string]string{
"file_read": helpFileRead,
"file_search": helpFileSearch,
"ast_query": helpASTQuery,
"lsp_query": helpLSPQuery,
"edit_apply": helpEditApply,
}
// registerResources registers one filepuff://help/<tool> resource per tool.
// Each resource returns Markdown-formatted flag docs and examples.
func (s *Server) registerResources() {
for toolName, content := range helpResources {
uri := "filepuff://help/" + toolName
name := "help/" + toolName
description := "Flag documentation and examples for the " + toolName + " tool."
captured := content // capture for closure
s.mcp.AddResource(
mcp.NewResource(uri, name,
mcp.WithResourceDescription(description),
mcp.WithMIMEType("text/markdown"),
),
func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: uri,
MIMEType: "text/markdown",
Text: captured,
},
}, nil
},
)
}
}
// readHelpResource is a convenience handler that can be used directly when a
// single resource handler is needed. It is kept exported for testability.
func readHelpResource(uri string) mcpserver.ResourceHandlerFunc {
return func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
// Extract tool name from filepuff://help/<tool>
const prefix = "filepuff://help/"
if len(uri) <= len(prefix) {
return nil, fmt.Errorf("invalid help URI: %s", uri)
}
toolName := uri[len(prefix):]
content, ok := helpResources[toolName]
if !ok {
return nil, fmt.Errorf("no help content for tool: %s", toolName)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: uri,
MIMEType: "text/markdown",
Text: content,
},
}, nil
}
}
// registerReadResource registers the filepuff://read/{+path} resource template.
// The handler re-reads the file, validates the etag query param if provided,
// and returns the raw file content (no line-number formatting).
//
// URI format: filepuff://read/<absolute-path>?etag=<etag>
// The etag param is optional. If supplied and the file has changed, the handler
// returns an error so the caller re-runs file_read to get a fresh ResourceLink.
func (s *Server) registerReadResource() {
const uriTemplate = "filepuff://read/{+path}"
s.mcp.AddResourceTemplate(
mcp.NewResourceTemplate(uriTemplate, "file-read",
mcp.WithTemplateDescription("Raw content of a file previously read via file_read. "+
"Fetch when file_read returns a ResourceLink instead of inlining content. "+
"URI: filepuff://read/<path>?etag=<etag>"),
),
func(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return s.handleReadResource(req)
},
)
}
// handleReadResource is the resource handler for filepuff://read/{+path} URIs.
func (s *Server) handleReadResource(req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
rawURI := req.Params.URI
// Parse path and etag from the URI.
// URI shape: filepuff://read/<path>[?etag=<hex>]
const scheme = "filepuff://read/"
if !strings.HasPrefix(rawURI, scheme) {
return nil, fmt.Errorf("invalid read resource URI: %s", rawURI)
}
rest := rawURI[len(scheme):]
// Split off query string to get the path.
filePath := rest
var expectedEtag string
if qIdx := strings.IndexByte(rest, '?'); qIdx >= 0 {
filePath = rest[:qIdx]
qs, err := url.ParseQuery(rest[qIdx+1:])
if err == nil {
expectedEtag = qs.Get("etag")
}
}
if filePath == "" {
return nil, fmt.Errorf("read resource URI missing path")
}
if !s.cfg.IsPathAllowed(filePath) {
return nil, fmt.Errorf("path is outside workspace root")
}
content, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", filePath)
}
if os.IsPermission(err) {
return nil, fmt.Errorf("permission denied: %s", filePath)
}
return nil, fmt.Errorf("error reading file: %s", filePath)
}
// Validate etag if provided — detect stale references.
if expectedEtag != "" {
fullHash := fmt.Sprintf("%016x", xxhash.Sum64(content))
currentEtag := fullHash[:8]
if expectedEtag != currentEtag && !strings.HasPrefix(fullHash, expectedEtag) && !strings.HasPrefix(expectedEtag, currentEtag) {
return nil, fmt.Errorf("file changed since ResourceLink was issued (expected etag %s, got %s); re-run file_read to get fresh content", expectedEtag, currentEtag)
}
}
mimeType := detectMIMEType(filePath)
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: rawURI,
MIMEType: mimeType,
Text: string(content),
},
}, nil
}
+90
View File
@@ -0,0 +1,90 @@
package server
import (
"context"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestRegisterResources_AllToolsHaveResource(t *testing.T) {
// Verify that registerResources wires up without panicking.
_ = newTestServer(t, t.TempDir())
expectedURIs := []string{
"filepuff://help/file_read",
"filepuff://help/file_search",
"filepuff://help/ast_query",
"filepuff://help/lsp_query",
"filepuff://help/edit_apply",
}
for _, uri := range expectedURIs {
t.Run(uri, func(t *testing.T) {
handler := readHelpResource(uri)
contents, err := handler(context.Background(), mcp.ReadResourceRequest{})
if err != nil {
t.Fatalf("readHelpResource(%q) error = %v", uri, err)
}
if len(contents) == 0 {
t.Fatalf("readHelpResource(%q) returned empty contents", uri)
}
tc, ok := contents[0].(mcp.TextResourceContents)
if !ok {
t.Fatalf("readHelpResource(%q) contents[0] is not TextResourceContents", uri)
}
if tc.MIMEType != "text/markdown" {
t.Errorf("MIMEType = %q, want %q", tc.MIMEType, "text/markdown")
}
if len(tc.Text) == 0 {
t.Errorf("Text is empty for %q", uri)
}
if !strings.HasPrefix(tc.Text, "#") {
t.Errorf("expected markdown (# heading) for %q, got: %q", uri, tc.Text[:min(50, len(tc.Text))])
}
if tc.URI != uri {
t.Errorf("URI = %q, want %q", tc.URI, uri)
}
})
}
}
func TestReadHelpResource_UnknownTool(t *testing.T) {
handler := readHelpResource("filepuff://help/nonexistent")
_, err := handler(context.Background(), mcp.ReadResourceRequest{})
if err == nil {
t.Fatal("expected error for unknown tool, got nil")
}
}
func TestReadHelpResource_InvalidURI(t *testing.T) {
handler := readHelpResource("filepuff://help/")
_, err := handler(context.Background(), mcp.ReadResourceRequest{})
if err == nil {
t.Fatal("expected error for empty tool name, got nil")
}
}
func TestHelpContent_NotEmpty(t *testing.T) {
cases := map[string]string{
"file_read": helpFileRead,
"file_search": helpFileSearch,
"ast_query": helpASTQuery,
"lsp_query": helpLSPQuery,
"edit_apply": helpEditApply,
}
for name, content := range cases {
t.Run(name, func(t *testing.T) {
if len(content) == 0 {
t.Errorf("help content for %q is empty", name)
}
if !strings.Contains(content, "##") {
t.Errorf("expected markdown sections (##) in help content for %q", name)
}
if !strings.Contains(content, "```") {
t.Errorf("expected code fences in help content for %q", name)
}
})
}
}
+198 -756
View File
File diff suppressed because it is too large Load Diff
+447 -92
View File
@@ -5,7 +5,9 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/mark3labs/mcp-go/mcp"
@@ -29,8 +31,8 @@ func TestNew(t *testing.T) {
if srv == nil {
t.Fatal("New() returned nil server")
return
}
if srv.cfg != cfg {
t.Error("server config mismatch")
}
@@ -48,44 +50,6 @@ func TestNew(t *testing.T) {
}
}
func TestHandlePing(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
result, err := srv.handlePing(ctx, req)
if err != nil {
t.Errorf("handlePing() error = %v", err)
}
if result == nil {
t.Fatal("handlePing() returned nil result")
}
// Check that the result contains "pong"
contents := result.Content
if len(contents) == 0 {
t.Fatal("handlePing() returned empty content")
}
textContent, ok := contents[0].(mcp.TextContent)
if !ok {
t.Fatal("handlePing() did not return text content")
}
if textContent.Text != "pong" {
t.Errorf("handlePing() = %v, want 'pong'", textContent.Text)
}
}
func TestHandleFileRead(t *testing.T) {
tmpDir := t.TempDir()
@@ -123,6 +87,7 @@ func Hello() {
if result == nil {
t.Fatal("handleFileRead() returned nil result")
return
}
contents := result.Content
@@ -179,6 +144,7 @@ func Hello() {
if result == nil {
t.Fatal("handleFileRead() returned nil result")
return
}
contents := result.Content
@@ -270,6 +236,7 @@ func Goodbye() error {
if result == nil {
t.Fatal("handleASTQuery() returned nil result")
return
}
contents := result.Content
@@ -278,55 +245,6 @@ func Goodbye() error {
}
}
func TestHandleEditPreview(t *testing.T) {
tmpDir := t.TempDir()
// Create a test file
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func Hello() {
println("Hello")
}
`
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Hello",
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
}
result, err := srv.handleEdit(ctx, req, false)
if err != nil {
t.Errorf("handleEdit(preview) error = %v", err)
}
if result == nil {
t.Fatal("handleEdit(preview) returned nil result")
}
// Verify file was NOT modified (it's just a preview)
fileContent, _ := os.ReadFile(testFile)
if string(fileContent) != content {
t.Error("handleEdit(preview) should not modify the file")
}
}
func TestHandleEditApply(t *testing.T) {
tmpDir := t.TempDir()
@@ -360,18 +278,455 @@ func Hello() {
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
}
result, err := srv.handleEdit(ctx, req, true)
result, err := srv.handleEditApply(ctx, req)
if err != nil {
t.Errorf("handleEdit(apply) error = %v", err)
t.Errorf("handleEditApply error = %v", err)
}
if result == nil {
t.Fatal("handleEdit(apply) returned nil result")
t.Fatal("handleEditApply returned nil result")
return
}
// Verify file WAS modified
fileContent, _ := os.ReadFile(testFile)
if string(fileContent) == content {
t.Error("handleEdit(apply) should modify the file")
t.Error("handleEditApply should modify the file")
}
}
// TestHandleFileReadMaxFileSize verifies that handleFileRead rejects files
// exceeding MaxFileSize via os.Stat before loading them into memory (T-03, S-01).
func TestHandleFileReadMaxFileSize(t *testing.T) {
tmpDir := t.TempDir()
// Create a test file
testFile := filepath.Join(tmpDir, "big.txt")
content := make([]byte, 1024) // 1KB file
for i := range content {
content[i] = 'A'
}
if err := os.WriteFile(testFile, content, 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
// Set MaxFileSize smaller than the file
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: 512, // 512 bytes — smaller than our 1KB file
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"path": testFile,
}
result, err := srv.handleFileRead(ctx, req)
if err != nil {
t.Fatalf("handleFileRead() returned Go error: %v", err)
}
// The result should indicate an error (file too large)
if result == nil {
t.Fatal("handleFileRead() returned nil result")
}
if !result.IsError {
t.Error("expected IsError=true for file exceeding MaxFileSize")
}
contents := result.Content
if len(contents) == 0 {
t.Fatal("expected non-empty content with error message")
}
textContent, ok := contents[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
if !strings.Contains(textContent.Text, "too large") {
t.Errorf("expected 'too large' error message, got: %s", textContent.Text)
}
}
// TestSplitLinesLargeFile tests the splitLines function with a large file (>1MB)
// to exercise the bufio.Scanner path including the scanner.Err() check (T-07, C-05).
func TestSplitLinesLargeFile(t *testing.T) {
// Build a string >1MB with known line count
lineCount := 20000
var sb strings.Builder
for i := 0; i < lineCount; i++ {
sb.WriteString(strings.Repeat("x", 60))
sb.WriteByte('\n')
}
largeContent := sb.String()
// Verify it's large enough to trigger the scanner path
if len(largeContent) <= 1024*1024 {
t.Fatalf("test content too small: %d bytes, need >1MB", len(largeContent))
}
lines := splitLines(largeContent)
// String ends with \n, so splitLines adds an empty trailing element
// (matching the behavior of strings.Split for the small-file path)
expectedLines := lineCount + 1 // lineCount lines + 1 trailing empty
if len(lines) != expectedLines {
t.Errorf("splitLines returned %d lines, expected %d", len(lines), expectedLines)
}
// Check first and last actual lines
if lines[0] != strings.Repeat("x", 60) {
t.Errorf("first line mismatch: got %q", lines[0][:20])
}
if lines[lineCount-1] != strings.Repeat("x", 60) {
t.Errorf("last content line mismatch: got %q", lines[lineCount-1][:20])
}
if lines[lineCount] != "" {
t.Errorf("expected empty trailing line, got %q", lines[lineCount])
}
}
// TestSplitLinesLargeFileNoTrailingNewline verifies splitLines for large files
// without a trailing newline.
func TestSplitLinesLargeFileNoTrailingNewline(t *testing.T) {
lineCount := 20000
var sb strings.Builder
for i := 0; i < lineCount; i++ {
if i > 0 {
sb.WriteByte('\n')
}
sb.WriteString(strings.Repeat("y", 60))
}
largeContent := sb.String()
if len(largeContent) <= 1024*1024 {
t.Fatalf("test content too small: %d bytes", len(largeContent))
}
lines := splitLines(largeContent)
if len(lines) != lineCount {
t.Errorf("splitLines returned %d lines, expected %d", len(lines), lineCount)
}
}
// 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
// (up to the 1MB buffer limit set in C-05 fix).
func TestSplitLinesLongLine(t *testing.T) {
// Create content with one very long line (500KB) embedded in a >1MB file
shortLines := strings.Repeat("short line content\n", 60000) // ~60KB * ~1 = ~1.08MB
longLine := strings.Repeat("L", 500*1024) // 500KB line
largeContent := shortLines + longLine + "\n"
if len(largeContent) <= 1024*1024 {
t.Fatalf("test content too small: %d bytes", len(largeContent))
}
lines := splitLines(largeContent)
// Should not crash and should return some lines
if len(lines) == 0 {
t.Fatal("splitLines returned no lines for valid content")
}
// The long line should be present somewhere in the output
foundLong := false
for _, line := range lines {
if len(line) >= 500*1024 {
foundLong = true
break
}
}
if !foundLong {
t.Error("the 500KB long line was not found in splitLines output")
}
}
// TestHandleEditApplyResponseCount verifies the default response=count format "+N -M".
func TestHandleEditApplyResponseCount(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func Hello() {
println("Hello")
}
`
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Hello",
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
// no response flag → default "count"
}
result, err := srv.handleEditApply(ctx, req)
if err != nil {
t.Fatalf("handleEditApply error = %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("handleEditApply returned empty result")
}
textContent, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
// Should be "+N -M" format
text := textContent.Text
if !strings.HasPrefix(text, "+") {
t.Errorf("response=count should start with +, got: %q", text)
}
if !strings.Contains(text, " -") {
t.Errorf("response=count should contain -N, got: %q", text)
}
// Must NOT contain diff syntax or old preamble
if strings.Contains(text, "@@") || strings.Contains(text, "Edit Applied") {
t.Errorf("response=count must not contain diff markers, got: %q", text)
}
}
// TestHandleEditApplyResponseDiff verifies response=diff returns unified diff.
func TestHandleEditApplyResponseDiff(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func Hello() {
println("Hello")
}
`
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Hello",
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
"response": "diff",
}
result, err := srv.handleEditApply(ctx, req)
if err != nil {
t.Fatalf("handleEditApply error = %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("handleEditApply returned empty result")
}
textContent, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
text := textContent.Text
if !strings.Contains(text, "diff") {
t.Errorf("response=diff should contain diff, got: %q", text)
}
// Must NOT have old "Edit Applied Successfully" preamble
if strings.Contains(text, "Edit Applied Successfully") {
t.Errorf("v2 diff should not have old preamble, got: %q", text)
}
}
// TestHandleEditApplyResponseNone verifies response=none returns empty string.
func TestHandleEditApplyResponseNone(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func Hello() {
println("Hello")
}
`
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Hello",
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
"response": "none",
}
result, err := srv.handleEditApply(ctx, req)
if err != nil {
t.Fatalf("handleEditApply error = %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("handleEditApply returned empty result")
}
textContent, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
if textContent.Text != "" {
t.Errorf("response=none should return empty string, got: %q", textContent.Text)
}
}
// TestHandleFileReadBatchDedup verifies that identical files in batch mode emit [duplicate of ...].
func TestHandleFileReadBatchDedup(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "a.go")
content := `package main
func Hello() {}
`
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write a.go: %v", err)
}
// Make b.go with identical content
testFile2 := filepath.Join(tmpDir, "b.go")
if err := os.WriteFile(testFile2, []byte(content), 0600); err != nil {
t.Fatalf("failed to write b.go: %v", err)
}
// c.go with different content
testFile3 := filepath.Join(tmpDir, "c.go")
if err := os.WriteFile(testFile3, []byte("package main\n"), 0600); err != nil {
t.Fatalf("failed to write c.go: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false, MaxFileSize: 1024 * 1024}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"paths": []interface{}{testFile, testFile2, testFile3},
}
result, err := srv.handleFileRead(ctx, req)
if err != nil {
t.Fatalf("handleFileRead() error = %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("handleFileRead() returned empty result")
}
textContent, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
text := textContent.Text
if !strings.Contains(text, "[duplicate of") {
t.Errorf("expected duplicate pointer for b.go, got:\n%s", text)
}
// a.go should have full content
if !strings.Contains(text, "--- "+testFile+" ---") {
t.Errorf("expected a.go header, got:\n%s", text)
}
// c.go should have full content (different hash)
if !strings.Contains(text, "--- "+testFile3+" ---") {
t.Errorf("expected c.go header, got:\n%s", text)
}
}
// TestHandleFileSearchVerbose verifies verbose=true emits "Found N matches in M files:" preamble.
func TestHandleFileSearchVerbose(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
if err := os.WriteFile(testFile, []byte("package main\n\nfunc Hello() {}\n"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false, MaxFileSize: 1024 * 1024, SearchTimeout: 10 * time.Second}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
if srv.searcher == nil {
t.Skip("ripgrep not available")
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "Hello",
"paths": []interface{}{tmpDir},
"verbose": true,
}
result, err := srv.handleFileSearch(ctx, req)
if err != nil {
t.Fatalf("handleFileSearch error = %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("handleFileSearch returned empty")
}
textContent, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
if !strings.Contains(textContent.Text, "Found ") {
t.Errorf("verbose=true should emit preamble, got:\n%s", textContent.Text)
}
}
+145
View File
@@ -0,0 +1,145 @@
// Package server implements the MCP server for file operations.
package server
import (
"sync/atomic"
"unsafe"
)
// SessionPrefs holds client-declared session-wide preferences parsed from
// InitializeRequest.Params.Capabilities.Experimental["filepuff"].
//
// These act as defaults; explicit per-call flags always override them.
//
// Supported keys in the "filepuff" experimental map:
//
// terse bool — no-op (v2 default is already terse; reserved for future)
// default_format string — ast_query format default ("verbose"|"compact"|"location")
// default_max_results int — file_search and ast_query max_results when not supplied
// default_cluster bool — file_search cluster default
// compact_refs bool — lsp_query references compact default
// line_numbers string — file_read line prefix default ("none"|"compact"|"full")
// resource_link_threshold int — per-session override for cfg.ResourceLinkThresholdBytes
type SessionPrefs struct {
// ASTQueryFormat is the default format for ast_query ("verbose", "compact", "location").
// Empty string means "use handler built-in default".
ASTQueryFormat string
// DefaultMaxResults is the default max_results for file_search and ast_query.
// 0 means "use handler built-in default".
DefaultMaxResults int
// DefaultCluster is the default cluster flag for file_search.
// nil means "use handler built-in default (false)".
DefaultCluster *bool
// CompactRefs is the default compact flag for lsp_query action=references.
// nil means "use handler built-in default (false)".
CompactRefs *bool
// LineNumbers controls the file_read line prefix default.
// "" = use handler built-in default (full).
// "none" = no line numbers.
// "compact" = compact prefix (N│content).
// "full" = standard padded prefix ( N│ content).
LineNumbers string
// ResourceLinkThreshold overrides cfg.ResourceLinkThresholdBytes for this session.
// 0 means "use config default".
ResourceLinkThreshold int
}
// boolPtr returns a pointer to a bool value.
func boolPtr(b bool) *bool { return &b }
// ParseSessionPrefs parses the raw map from
// InitializeRequest.Params.Capabilities.Experimental["filepuff"].
// Unknown keys are silently ignored. Type mismatches for individual keys are
// silently ignored (key is treated as absent). Returns zero-value SessionPrefs
// when raw is nil or empty — callers should treat zero values as "use built-in defaults".
func ParseSessionPrefs(raw map[string]any) SessionPrefs {
if len(raw) == 0 {
return SessionPrefs{}
}
var p SessionPrefs
if v, ok := raw["default_format"]; ok {
if s, ok := v.(string); ok {
switch s {
case "verbose", "compact", "location":
p.ASTQueryFormat = s
}
}
}
if v, ok := raw["default_max_results"]; ok {
if n := toInt(v); n > 0 {
p.DefaultMaxResults = n
}
}
if v, ok := raw["default_cluster"]; ok {
if b, ok := v.(bool); ok {
p.DefaultCluster = boolPtr(b)
}
}
if v, ok := raw["compact_refs"]; ok {
if b, ok := v.(bool); ok {
p.CompactRefs = boolPtr(b)
}
}
if v, ok := raw["line_numbers"]; ok {
if s, ok := v.(string); ok {
switch s {
case "none", "compact", "full":
p.LineNumbers = s
}
}
}
if v, ok := raw["resource_link_threshold"]; ok {
if n := toInt(v); n >= 0 {
p.ResourceLinkThreshold = n
}
}
return p
}
// toInt converts numeric JSON-decoded values (float64, int, int64) to int.
// Returns 0 for unsupported types or negative values.
func toInt(v any) int {
switch n := v.(type) {
case float64:
if n >= 0 {
return int(n)
}
case int:
if n >= 0 {
return n
}
case int64:
if n >= 0 {
return int(n)
}
}
return 0
}
// sessionPrefsPtr is an atomic pointer helper for thread-safe access to *SessionPrefs.
// We use atomic store/load so the hook (called once at init) and handlers (many goroutines)
// never race. The prefs are write-once after initialization.
type sessionPrefsPtr struct {
p unsafe.Pointer // *SessionPrefs
}
func (sp *sessionPrefsPtr) Store(prefs *SessionPrefs) {
atomic.StorePointer(&sp.p, unsafe.Pointer(prefs))
}
func (sp *sessionPrefsPtr) Load() *SessionPrefs {
return (*SessionPrefs)(atomic.LoadPointer(&sp.p))
}
+313
View File
@@ -0,0 +1,313 @@
package server
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/mark3labs/mcp-go/mcp"
)
// ---- Session prefs integration tests ----
// setSessionPrefs injects prefs directly on the server (bypasses MCP hook machinery).
func setSessionPrefs(srv *Server, prefs SessionPrefs) {
srv.sessionPrefs.Store(&prefs)
}
// TestSessionPrefsFileReadLineNumbersNone verifies that session pref line_numbers=none
// disables line-number prefixes and is overridden by explicit compact_line_numbers=true.
func TestSessionPrefsFileReadLineNumbersNone(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
content := "package main\n\nfunc Foo() {}\n"
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("write file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New: %v", err)
}
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"line_numbers": "none"}))
ctx := context.Background()
// Without explicit override: session pref should suppress line numbers.
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{"path": testFile}
result, err := srv.handleFileRead(ctx, req)
if err != nil {
t.Fatalf("handleFileRead: %v", err)
}
text := result.Content[0].(mcp.TextContent).Text
// line_numbers=none suppresses the prefix entirely (no "│" gutter at all).
if strings.Contains(text, " 1│") {
t.Errorf("session line_numbers=none: expected no line-number prefix, got:\n%s", text)
}
// Explicit per-call compact_line_numbers=true should override session none.
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{
"path": testFile,
"compact_line_numbers": true,
}
result2, err := srv.handleFileRead(ctx, req2)
if err != nil {
t.Fatalf("handleFileRead (explicit compact): %v", err)
}
text2 := result2.Content[0].(mcp.TextContent).Text
// Compact format emits "1│" prefix.
if !strings.Contains(text2, "\u2502") {
t.Errorf("explicit compact_line_numbers should override session none, got:\n%s", text2)
}
}
// TestSessionPrefsFileReadLineNumbersCompact verifies line_numbers=compact session pref.
func TestSessionPrefsFileReadLineNumbersCompact(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "x.go")
if err := os.WriteFile(testFile, []byte("package main\nfunc Bar() {}\n"), 0600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, _ := New(cfg, logger)
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"line_numbers": "compact"}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{"path": testFile}
result, _ := srv.handleFileRead(ctx, req)
text := result.Content[0].(mcp.TextContent).Text
// Padded prefix is " 1│ "; compact is "1│".
if strings.Contains(text, " 1\u2502") {
t.Errorf("session line_numbers=compact should use compact prefix, got:\n%s", text)
}
// Should still have the │ separator somewhere.
if !strings.Contains(text, "\u2502") {
t.Errorf("session line_numbers=compact should still have \u2502 separator, got:\n%s", text)
}
}
// TestSessionPrefsResourceLinkThreshold verifies per-session threshold override.
func TestSessionPrefsResourceLinkThreshold(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "big.go")
var sb strings.Builder
sb.WriteString("package main\n\nfunc Foo() {\n")
for i := 0; i < 15; i++ {
sb.WriteString("// comment line\n")
}
sb.WriteString("}\n")
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
t.Fatalf("write: %v", err)
}
// Config threshold = 0 (disabled) so content is always inlined by default.
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20, ResourceLinkThresholdBytes: 0}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, _ := New(cfg, logger)
// Set session threshold = 10 bytes (tiny), so any real file triggers resource-link.
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"resource_link_threshold": float64(10)}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{"path": testFile}
result, _ := srv.handleFileRead(ctx, req)
if len(result.Content) == 0 {
t.Fatal("expected content")
}
_, isLink := result.Content[0].(mcp.ResourceLink)
_, isText := result.Content[0].(mcp.TextContent)
if !isLink && isText {
t.Error("expected ResourceLink when session threshold is very small, got TextContent")
}
// force_inline should still bypass even a session threshold.
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{"path": testFile, "force_inline": true}
result2, _ := srv.handleFileRead(ctx, req2)
if _, ok := result2.Content[0].(mcp.TextContent); !ok {
t.Error("force_inline=true should bypass session threshold and return TextContent")
}
}
// TestSessionPrefsASTQueryFormat verifies default_format session pref for ast_query.
func TestSessionPrefsASTQueryFormat(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
if err := os.WriteFile(testFile, []byte("package main\n\nfunc Greet() {}\n"), 0600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, _ := New(cfg, logger)
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"default_format": "compact"}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
// no format key → session default should apply
}
result, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("handleASTQuery: %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("empty result")
}
text := result.Content[0].(mcp.TextContent).Text
// Compact format emits one-line results without "**file:line**" markers.
if strings.Contains(text, "**") {
t.Errorf("session default_format=compact: expected compact output (no **), got:\n%s", text)
}
// Explicit format=verbose should override session compact.
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
"format": "verbose",
}
result2, _ := srv.handleASTQuery(ctx, req2)
if result2 != nil && len(result2.Content) > 0 {
text2 := result2.Content[0].(mcp.TextContent).Text
if !strings.Contains(text2, "**") {
t.Errorf("explicit format=verbose should override session compact, got:\n%s", text2)
}
}
}
// TestSessionPrefsASTQueryMaxResults verifies default_max_results for ast_query.
func TestSessionPrefsASTQueryMaxResults(t *testing.T) {
tmpDir := t.TempDir()
// Build a file with 5 functions.
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < 5; i++ {
fmt.Fprintf(&sb, "func Fn%c() {}\n\n", rune('A'+i))
}
testFile := filepath.Join(tmpDir, "many.go")
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, _ := New(cfg, logger)
// Session pref: max 2 results.
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"default_max_results": float64(2)}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
// no max_results → session pref of 2 should apply
}
result, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("handleASTQuery: %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("empty result")
}
text := result.Content[0].(mcp.TextContent).Text
// With 5 funcs and max=2, output should mention remaining.
if !strings.Contains(text, "remaining") && !strings.Contains(text, "cursor") {
t.Errorf("session max_results=2 with 5 matches should produce cursor line, got:\n%s", text)
}
}
// TestSessionPrefsFileSearchDefaultCluster verifies default_cluster session pref.
func TestSessionPrefsFileSearchDefaultCluster(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "x.go")
content := "package main\n\nfunc Foo() {}\nfunc Foo2() {}\n"
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
MaxFileSize: 1 << 20,
SearchTimeout: 10 * time.Second,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New: %v", err)
}
if srv.searcher == nil {
t.Skip("ripgrep not available")
}
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"default_cluster": true}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
// no cluster flag → session default_cluster=true should apply
}
result, err := srv.handleFileSearch(ctx, req)
if err != nil {
t.Fatalf("handleFileSearch: %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Skip("search returned no results")
}
// Verify call succeeded (cluster behaviour is ripgrep-version dependent).
_ = result.Content[0].(mcp.TextContent).Text
}
// TestSessionPrefsMaxResultsExplicitOverride verifies explicit call-time max_results
// overrides session pref for ast_query.
func TestSessionPrefsMaxResultsExplicitOverride(t *testing.T) {
tmpDir := t.TempDir()
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < 5; i++ {
fmt.Fprintf(&sb, "func Fn%c() {}\n\n", rune('A'+i))
}
testFile := filepath.Join(tmpDir, "many.go")
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, _ := New(cfg, logger)
// Session wants 2, caller supplies 10 — all 5 should fit without cursor.
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"default_max_results": float64(2)}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
"max_results": 10, // explicit override
}
result, _ := srv.handleASTQuery(ctx, req)
if result == nil || len(result.Content) == 0 {
t.Fatal("empty result")
}
text := result.Content[0].(mcp.TextContent).Text
// With max_results=10 and only 5 funcs, no cursor line expected.
if strings.Contains(text, "remaining") {
t.Errorf("explicit max_results=10 should override session 2; 5 funcs fit; no cursor expected, got:\n%s", text)
}
}
+186
View File
@@ -0,0 +1,186 @@
package server
import (
"testing"
)
// TestParseSessionPrefsEmpty verifies zero-value result for nil/empty input.
func TestParseSessionPrefsEmpty(t *testing.T) {
p := ParseSessionPrefs(nil)
if p.ASTQueryFormat != "" {
t.Errorf("ASTQueryFormat: want \"\", got %q", p.ASTQueryFormat)
}
if p.DefaultMaxResults != 0 {
t.Errorf("DefaultMaxResults: want 0, got %d", p.DefaultMaxResults)
}
if p.DefaultCluster != nil {
t.Errorf("DefaultCluster: want nil, got %v", *p.DefaultCluster)
}
if p.CompactRefs != nil {
t.Errorf("CompactRefs: want nil, got %v", *p.CompactRefs)
}
if p.LineNumbers != "" {
t.Errorf("LineNumbers: want \"\", got %q", p.LineNumbers)
}
if p.ResourceLinkThreshold != 0 {
t.Errorf("ResourceLinkThreshold: want 0, got %d", p.ResourceLinkThreshold)
}
// Also test with an empty (non-nil) map.
p2 := ParseSessionPrefs(map[string]any{})
if p2.ASTQueryFormat != "" || p2.DefaultMaxResults != 0 {
t.Error("empty map should produce zero-value prefs")
}
}
// TestParseSessionPrefsAllFields verifies full round-trip with all supported keys.
func TestParseSessionPrefsAllFields(t *testing.T) {
raw := map[string]any{
"terse": true, // no-op; should not produce an error
"default_format": "compact",
"default_max_results": float64(50), // JSON numbers decode as float64
"default_cluster": true,
"compact_refs": true,
"line_numbers": "none",
"resource_link_threshold": float64(32768),
}
p := ParseSessionPrefs(raw)
if p.ASTQueryFormat != "compact" {
t.Errorf("ASTQueryFormat: want \"compact\", got %q", p.ASTQueryFormat)
}
if p.DefaultMaxResults != 50 {
t.Errorf("DefaultMaxResults: want 50, got %d", p.DefaultMaxResults)
}
if p.DefaultCluster == nil || !*p.DefaultCluster {
t.Errorf("DefaultCluster: want true, got %v", p.DefaultCluster)
}
if p.CompactRefs == nil || !*p.CompactRefs {
t.Errorf("CompactRefs: want true, got %v", p.CompactRefs)
}
if p.LineNumbers != "none" {
t.Errorf("LineNumbers: want \"none\", got %q", p.LineNumbers)
}
if p.ResourceLinkThreshold != 32768 {
t.Errorf("ResourceLinkThreshold: want 32768, got %d", p.ResourceLinkThreshold)
}
}
// TestParseSessionPrefsLineNumbersVariants tests all valid line_numbers values.
func TestParseSessionPrefsLineNumbersVariants(t *testing.T) {
for _, want := range []string{"none", "compact", "full"} {
p := ParseSessionPrefs(map[string]any{"line_numbers": want})
if p.LineNumbers != want {
t.Errorf("line_numbers=%q: got %q", want, p.LineNumbers)
}
}
// Invalid value → ignored (empty string).
p := ParseSessionPrefs(map[string]any{"line_numbers": "bogus"})
if p.LineNumbers != "" {
t.Errorf("invalid line_numbers should be ignored, got %q", p.LineNumbers)
}
}
// TestParseSessionPrefsFormatVariants tests all valid default_format values.
func TestParseSessionPrefsFormatVariants(t *testing.T) {
for _, want := range []string{"verbose", "compact", "location"} {
p := ParseSessionPrefs(map[string]any{"default_format": want})
if p.ASTQueryFormat != want {
t.Errorf("default_format=%q: got %q", want, p.ASTQueryFormat)
}
}
// Invalid value → ignored.
p := ParseSessionPrefs(map[string]any{"default_format": "yaml"})
if p.ASTQueryFormat != "" {
t.Errorf("invalid format should be ignored, got %q", p.ASTQueryFormat)
}
}
// TestParseSessionPrefsTypeMismatch verifies that wrong types are silently ignored.
func TestParseSessionPrefsTypeMismatch(t *testing.T) {
raw := map[string]any{
"default_format": 123, // wrong type (int instead of string)
"default_max_results": "fifty", // wrong type
"default_cluster": "yes", // wrong type (string instead of bool)
"compact_refs": 42, // wrong type
"line_numbers": true, // wrong type
"resource_link_threshold": "big", // wrong type
}
p := ParseSessionPrefs(raw)
if p.ASTQueryFormat != "" {
t.Errorf("type mismatch for format should produce empty, got %q", p.ASTQueryFormat)
}
if p.DefaultMaxResults != 0 {
t.Errorf("type mismatch for max_results should produce 0, got %d", p.DefaultMaxResults)
}
if p.DefaultCluster != nil {
t.Errorf("type mismatch for cluster should produce nil")
}
if p.CompactRefs != nil {
t.Errorf("type mismatch for compact_refs should produce nil")
}
if p.LineNumbers != "" {
t.Errorf("type mismatch for line_numbers should produce empty, got %q", p.LineNumbers)
}
if p.ResourceLinkThreshold != 0 {
t.Errorf("type mismatch for threshold should produce 0, got %d", p.ResourceLinkThreshold)
}
}
// TestParseSessionPrefsNegativeValues verifies negative numbers are rejected.
func TestParseSessionPrefsNegativeValues(t *testing.T) {
p := ParseSessionPrefs(map[string]any{
"default_max_results": float64(-5),
"resource_link_threshold": float64(-1),
})
if p.DefaultMaxResults != 0 {
t.Errorf("negative max_results should be rejected, got %d", p.DefaultMaxResults)
}
if p.ResourceLinkThreshold != 0 {
t.Errorf("negative threshold should be rejected, got %d", p.ResourceLinkThreshold)
}
}
// TestParseSessionPrefsIntCoercion verifies int and int64 inputs also work.
func TestParseSessionPrefsIntCoercion(t *testing.T) {
p := ParseSessionPrefs(map[string]any{
"default_max_results": int(25),
"resource_link_threshold": int64(16384),
})
if p.DefaultMaxResults != 25 {
t.Errorf("int max_results: want 25, got %d", p.DefaultMaxResults)
}
if p.ResourceLinkThreshold != 16384 {
t.Errorf("int64 threshold: want 16384, got %d", p.ResourceLinkThreshold)
}
}
// TestParseSessionPrefsClusterFalse ensures default_cluster=false stores a non-nil false.
func TestParseSessionPrefsClusterFalse(t *testing.T) {
p := ParseSessionPrefs(map[string]any{"default_cluster": false})
if p.DefaultCluster == nil {
t.Error("default_cluster=false should store non-nil pointer")
}
if *p.DefaultCluster != false {
t.Error("default_cluster=false: want false pointer")
}
}
// TestSessionPrefsAtomicStore verifies sessionPrefsPtr is readable after Store.
func TestSessionPrefsAtomicStore(t *testing.T) {
var sp sessionPrefsPtr
if sp.Load() != nil {
t.Error("uninitialised Load() should return nil")
}
prefs := ParseSessionPrefs(map[string]any{"default_format": "compact"})
sp.Store(&prefs)
loaded := sp.Load()
if loaded == nil {
t.Fatal("Load() returned nil after Store")
}
if loaded.ASTQueryFormat != "compact" {
t.Errorf("loaded format: want compact, got %q", loaded.ASTQueryFormat)
}
}
+149
View File
@@ -0,0 +1,149 @@
// Package util provides shared utility functions and caches.
package util
import (
"fmt"
"regexp"
"sync"
)
const (
// MaxPatternLength is the maximum allowed length for regex patterns.
// This prevents memory issues from extremely long patterns.
MaxPatternLength = 1000
// MaxCacheSize is the maximum number of patterns to cache.
// When exceeded, the cache is cleared to prevent unbounded memory growth.
MaxCacheSize = 10000
)
// regexCache is a global thread-safe cache for compiled regular expressions.
// Uses sync.RWMutex with a regular map so that ClearRegexCache can atomically
// clear the map and reset the count in a single lock acquisition.
var (
cacheMu sync.RWMutex
regexCache = make(map[string]*regexp.Regexp)
)
// RegexError represents an error during regex compilation or validation.
type RegexError struct {
Pattern string
Reason string
Err error
}
func (e *RegexError) Error() string {
if e.Err != nil {
return fmt.Sprintf("regex error for pattern %q: %s: %v", e.Pattern, e.Reason, e.Err)
}
return fmt.Sprintf("regex error for pattern %q: %s", e.Pattern, e.Reason)
}
func (e *RegexError) Unwrap() error {
return e.Err
}
// ValidatePattern validates a regex pattern for safety.
// Returns an error if the pattern is too long or appears malicious.
func ValidatePattern(pattern string) error {
// Check pattern length
if len(pattern) > MaxPatternLength {
return &RegexError{
Pattern: truncatePattern(pattern),
Reason: fmt.Sprintf("pattern too long (%d chars, max %d)", len(pattern), MaxPatternLength),
}
}
// Note: Go's regexp package uses Thompson NFA which guarantees O(n) matching time,
// making it inherently resistant to ReDoS attacks. However, we still validate
// pattern length to prevent memory issues during compilation.
return nil
}
// CompileRegex compiles a regex pattern with caching and validation for security.
// Thread-safe: uses RWMutex to prevent race conditions.
// Returns the compiled regex or an error if the pattern is invalid or unsafe.
func CompileRegex(pattern string) (*regexp.Regexp, error) {
// Validate pattern first
if err := ValidatePattern(pattern); err != nil {
return nil, err
}
// Check cache first (read lock)
cacheMu.RLock()
if cached, ok := regexCache[pattern]; ok {
cacheMu.RUnlock()
return cached, nil
}
cacheMu.RUnlock()
// Compile regex outside the lock to avoid holding it during compilation
re, err := regexp.Compile(pattern)
if err != nil {
return nil, &RegexError{
Pattern: truncatePattern(pattern),
Reason: "invalid regex syntax",
Err: err,
}
}
// Write lock to store in cache
cacheMu.Lock()
// Re-check in case another goroutine stored it while we were compiling
if cached, ok := regexCache[pattern]; ok {
cacheMu.Unlock()
return cached, nil
}
// Check cache size and clear if too large
if len(regexCache) >= MaxCacheSize {
regexCache = make(map[string]*regexp.Regexp)
}
regexCache[pattern] = re
cacheMu.Unlock()
return re, nil
}
// CompileRegexUncached compiles a regex pattern without caching.
// Useful for one-off patterns that shouldn't pollute the cache.
func CompileRegexUncached(pattern string) (*regexp.Regexp, error) {
if err := ValidatePattern(pattern); err != nil {
return nil, err
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, &RegexError{
Pattern: truncatePattern(pattern),
Reason: "invalid regex syntax",
Err: err,
}
}
return re, nil
}
// ClearRegexCache clears all cached compiled regular expressions.
// Atomically replaces the map under a single write lock.
func ClearRegexCache() {
cacheMu.Lock()
regexCache = make(map[string]*regexp.Regexp)
cacheMu.Unlock()
}
// CacheStats returns the current number of cached patterns.
func CacheStats() int64 {
cacheMu.RLock()
n := int64(len(regexCache))
cacheMu.RUnlock()
return n
}
// truncatePattern truncates a pattern for display in error messages.
func truncatePattern(pattern string) string {
if len(pattern) > 50 {
return pattern[:47] + "..."
}
return pattern
}
+375
View File
@@ -0,0 +1,375 @@
package util
import (
"errors"
"strings"
"sync"
"testing"
)
func TestValidatePattern(t *testing.T) {
tests := []struct {
name string
pattern string
expectErr bool
}{
{
name: "valid short pattern",
pattern: "^hello.*world$",
expectErr: false,
},
{
name: "valid empty pattern",
pattern: "",
expectErr: false,
},
{
name: "valid pattern at max length",
pattern: strings.Repeat("a", MaxPatternLength),
expectErr: false,
},
{
name: "pattern too long",
pattern: strings.Repeat("a", MaxPatternLength+1),
expectErr: true,
},
{
name: "very long pattern",
pattern: strings.Repeat("x", MaxPatternLength*2),
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePattern(tt.pattern)
if tt.expectErr && err == nil {
t.Error("expected error but got nil")
}
if !tt.expectErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestCompileRegex(t *testing.T) {
// Clear cache before each test
ClearRegexCache()
t.Run("valid pattern is compiled and cached", func(t *testing.T) {
ClearRegexCache()
pattern := "^test.*pattern$"
re1, err := CompileRegex(pattern)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if re1 == nil {
t.Fatal("expected non-nil regex")
}
// Second call should return cached version
re2, err := CompileRegex(pattern)
if err != nil {
t.Fatalf("unexpected error on second call: %v", err)
}
// Should be the same pointer
if re1 != re2 {
t.Error("expected same regex instance from cache")
}
// Cache should have one entry
if stats := CacheStats(); stats != 1 {
t.Errorf("expected cache size 1, got %d", stats)
}
})
t.Run("invalid pattern returns error", func(t *testing.T) {
ClearRegexCache()
pattern := "[invalid(regex"
_, err := CompileRegex(pattern)
if err == nil {
t.Fatal("expected error for invalid regex")
}
var regexErr *RegexError
if !errors.As(err, &regexErr) {
t.Errorf("expected RegexError, got %T", err)
}
})
t.Run("pattern too long returns error", func(t *testing.T) {
ClearRegexCache()
pattern := strings.Repeat("a", MaxPatternLength+1)
_, err := CompileRegex(pattern)
if err == nil {
t.Fatal("expected error for long pattern")
}
var regexErr *RegexError
if !errors.As(err, &regexErr) {
t.Errorf("expected RegexError, got %T", err)
}
})
t.Run("different patterns are cached separately", func(t *testing.T) {
ClearRegexCache()
re1, _ := CompileRegex("pattern1")
re2, _ := CompileRegex("pattern2")
if re1 == re2 {
t.Error("different patterns should produce different regex instances")
}
if stats := CacheStats(); stats != 2 {
t.Errorf("expected cache size 2, got %d", stats)
}
})
t.Run("regex matches correctly", func(t *testing.T) {
ClearRegexCache()
re, err := CompileRegex("^hello\\s+world$")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !re.MatchString("hello world") {
t.Error("expected match for 'hello world'")
}
if !re.MatchString("hello world") {
t.Error("expected match for 'hello world'")
}
if re.MatchString("helloworld") {
t.Error("unexpected match for 'helloworld'")
}
})
}
func TestCompileRegexUncached(t *testing.T) {
ClearRegexCache()
t.Run("valid pattern compiles without caching", func(t *testing.T) {
initialSize := CacheStats()
re, err := CompileRegexUncached("^uncached.*pattern$")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if re == nil {
t.Fatal("expected non-nil regex")
}
// Cache size should not change
if stats := CacheStats(); stats != initialSize {
t.Errorf("cache size changed from %d to %d", initialSize, stats)
}
})
t.Run("invalid pattern returns error", func(t *testing.T) {
_, err := CompileRegexUncached("[invalid")
if err == nil {
t.Fatal("expected error for invalid regex")
}
})
t.Run("pattern too long returns error", func(t *testing.T) {
pattern := strings.Repeat("x", MaxPatternLength+1)
_, err := CompileRegexUncached(pattern)
if err == nil {
t.Fatal("expected error for long pattern")
}
})
}
func TestClearRegexCache(t *testing.T) {
// Add some patterns
_, _ = CompileRegex("pattern1")
_, _ = CompileRegex("pattern2")
_, _ = CompileRegex("pattern3")
if stats := CacheStats(); stats < 3 {
t.Fatalf("expected at least 3 cached patterns, got %d", stats)
}
ClearRegexCache()
if stats := CacheStats(); stats != 0 {
t.Errorf("expected cache size 0 after clear, got %d", stats)
}
}
func TestCacheStats(t *testing.T) {
ClearRegexCache()
if stats := CacheStats(); stats != 0 {
t.Errorf("expected initial cache size 0, got %d", stats)
}
_, _ = CompileRegex("a")
if stats := CacheStats(); stats != 1 {
t.Errorf("expected cache size 1, got %d", stats)
}
_, _ = CompileRegex("b")
if stats := CacheStats(); stats != 2 {
t.Errorf("expected cache size 2, got %d", stats)
}
// Same pattern should not increase cache size
_, _ = CompileRegex("a")
if stats := CacheStats(); stats != 2 {
t.Errorf("expected cache size 2 after duplicate, got %d", stats)
}
}
func TestConcurrentAccess(t *testing.T) {
ClearRegexCache()
var wg sync.WaitGroup
numGoroutines := 100
numPatterns := 10
// Generate some patterns
patterns := make([]string, numPatterns)
for i := range patterns {
patterns[i] = strings.Repeat("p", i+1)
}
// Concurrent compilation of same patterns
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
pattern := patterns[id%numPatterns]
re, err := CompileRegex(pattern)
if err != nil {
t.Errorf("goroutine %d: unexpected error: %v", id, err)
return
}
if re == nil {
t.Errorf("goroutine %d: nil regex returned", id)
}
}(i)
}
wg.Wait()
// Should have exactly numPatterns cached
if stats := CacheStats(); stats != int64(numPatterns) {
t.Errorf("expected cache size %d, got %d", numPatterns, stats)
}
}
func TestRegexError(t *testing.T) {
t.Run("error message with underlying error", func(t *testing.T) {
underlying := errors.New("underlying error")
err := &RegexError{
Pattern: "test.*",
Reason: "test reason",
Err: underlying,
}
msg := err.Error()
if !strings.Contains(msg, "test.*") {
t.Error("error message should contain pattern")
}
if !strings.Contains(msg, "test reason") {
t.Error("error message should contain reason")
}
if !strings.Contains(msg, "underlying error") {
t.Error("error message should contain underlying error")
}
})
t.Run("error message without underlying error", func(t *testing.T) {
err := &RegexError{
Pattern: "test.*",
Reason: "test reason",
Err: nil,
}
msg := err.Error()
if !strings.Contains(msg, "test.*") {
t.Error("error message should contain pattern")
}
if !strings.Contains(msg, "test reason") {
t.Error("error message should contain reason")
}
})
t.Run("error unwrap", func(t *testing.T) {
underlying := errors.New("underlying")
err := &RegexError{
Pattern: "test",
Reason: "reason",
Err: underlying,
}
if errors.Unwrap(err) != underlying {
t.Error("Unwrap should return underlying error")
}
})
}
func TestTruncatePattern(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "short pattern unchanged",
input: "short",
expected: "short",
},
{
name: "exactly 50 chars unchanged",
input: strings.Repeat("x", 50),
expected: strings.Repeat("x", 50),
},
{
name: "long pattern truncated",
input: strings.Repeat("x", 60),
expected: strings.Repeat("x", 47) + "...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncatePattern(tt.input)
if got != tt.expected {
t.Errorf("truncatePattern() = %q (len %d), want %q (len %d)",
got, len(got), tt.expected, len(tt.expected))
}
})
}
}
// BenchmarkCompileRegex benchmarks regex compilation with caching
func BenchmarkCompileRegex(b *testing.B) {
ClearRegexCache()
pattern := "^test.*pattern\\d+$"
// First call to populate cache
_, _ = CompileRegex(pattern)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = CompileRegex(pattern)
}
}
// BenchmarkCompileRegexUncached benchmarks regex compilation without caching
func BenchmarkCompileRegexUncached(b *testing.B) {
pattern := "^test.*pattern\\d+$"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = CompileRegexUncached(pattern)
}
}
+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])
}
})
}
}
+41 -5
View File
@@ -77,24 +77,24 @@ func (e *StructuredError) Error() string {
var sb strings.Builder
// 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
if len(e.Context) > 0 {
sb.WriteString("\nContext:")
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
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
if e.Cause != nil {
sb.WriteString(fmt.Sprintf("\nCaused by: %v", e.Cause))
fmt.Fprintf(&sb, "\nCaused by: %v", e.Cause)
}
return sb.String()
@@ -120,6 +120,42 @@ func (e *StructuredError) WithRemediation(msg string) *StructuredError {
return e
}
// UserMessage returns a user-safe error message without internal details.
// This should be used for API responses to avoid leaking implementation details.
func (e *StructuredError) UserMessage() string {
// Return only the message and remediation, no stack trace, no cause details
if e.Remediation != "" {
return fmt.Sprintf("%s. %s", e.Message, e.Remediation)
}
return e.Message
}
// SanitizeError returns a user-safe error message from any error.
// For StructuredError, it returns UserMessage().
// For other errors, it returns a generic message to avoid leaking internals.
func SanitizeError(err error) string {
if err == nil {
return ""
}
// Check if it's a StructuredError
if se, ok := err.(*StructuredError); ok {
return se.UserMessage()
}
// For other errors, extract only the basic message
// Avoid exposing full paths or implementation details
msg := err.Error()
// Truncate very long messages that might contain stack traces
const maxLen = 200
if len(msg) > maxLen {
msg = msg[:maxLen] + "..."
}
return msg
}
// New creates a new structured error with stack trace.
func New(code ErrorCode, message string) *StructuredError {
return &StructuredError{
@@ -179,7 +215,7 @@ func captureStack(skip int) string {
for {
frame, more := frames.Next()
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 {
break
+11 -13
View File
@@ -1,6 +1,8 @@
// Package protocol defines shared types used across the MCP file operations server.
package protocol
import "path/filepath"
// Location represents a position in a file.
type Location struct {
File string `json:"file"`
@@ -30,6 +32,8 @@ const (
SymbolProperty SymbolKind = "property"
SymbolModule SymbolKind = "module"
SymbolPackage SymbolKind = "package"
SymbolEnum SymbolKind = "enum"
SymbolTrait SymbolKind = "trait"
)
// Symbol represents a code symbol (function, class, variable, etc.).
@@ -60,12 +64,14 @@ const (
LangVue Language = "vue"
LangJSON Language = "json"
LangYAML Language = "yaml"
LangElixir Language = "elixir"
LangRust Language = "rust"
LangUnknown Language = "unknown"
)
// DetectLanguage detects the language from a filename.
func DetectLanguage(filename string) Language {
ext := getExtension(filename)
ext := filepath.Ext(filename)
switch ext {
case ".go":
return LangGo
@@ -87,19 +93,11 @@ func DetectLanguage(filename string) Language {
return LangJSON
case ".yaml", ".yml":
return LangYAML
case ".ex", ".exs":
return LangElixir
case ".rs":
return LangRust
default:
return LangUnknown
}
}
func getExtension(filename string) string {
for i := len(filename) - 1; i >= 0; i-- {
if filename[i] == '.' {
return filename[i:]
}
if filename[i] == '/' || filename[i] == '\\' {
break
}
}
return ""
}
+4 -23
View File
@@ -29,10 +29,14 @@ func TestDetectLanguage(t *testing.T) {
{"index.html", LangHTML},
{"page.htm", LangHTML},
{"Component.vue", LangVue},
{"app.ex", LangElixir},
{"app_test.exs", LangElixir},
{"mix.exs", LangElixir},
{"unknown.txt", LangUnknown},
{"README", LangUnknown},
{"path/to/file.go", LangGo},
{"path/to/file.ts", LangTypeScript},
{"path/to/file.ex", LangElixir},
}
for _, tt := range tests {
@@ -44,26 +48,3 @@ func TestDetectLanguage(t *testing.T) {
})
}
}
func TestGetExtension(t *testing.T) {
tests := []struct {
filename string
expected string
}{
{"file.go", ".go"},
{"file.test.go", ".go"},
{"path/to/file.ts", ".ts"},
{"noextension", ""},
{".hidden", ".hidden"},
{"file.", "."},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
result := getExtension(tt.filename)
if result != tt.expected {
t.Errorf("getExtension(%q) = %q, want %q", tt.filename, result, tt.expected)
}
})
}
}
+180 -7
View File
@@ -20,15 +20,15 @@ INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# Functions
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
print_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
printf "${YELLOW}[WARN]${NC} %s\n" "$1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
}
detect_platform() {
@@ -86,6 +86,108 @@ check_dependencies() {
fi
}
detect_package_manager() {
if command -v brew &> /dev/null; then
echo "brew"
elif command -v apt-get &> /dev/null; then
echo "apt"
elif command -v yum &> /dev/null; then
echo "yum"
elif command -v pacman &> /dev/null; then
echo "pacman"
elif command -v apk &> /dev/null; then
echo "apk"
else
echo "unknown"
fi
}
install_prerequisites() {
local pkg_mgr="$1"
local install_lsp="${2:-false}"
print_info "Checking prerequisites..."
# Check if ripgrep is installed
if ! command -v rg &> /dev/null; then
print_warn "ripgrep not found - required for file search functionality"
case "$pkg_mgr" in
brew)
print_info "Installing ripgrep via Homebrew..."
brew install ripgrep
;;
apt)
print_info "Installing ripgrep via apt..."
sudo apt-get update && sudo apt-get install -y ripgrep
;;
yum)
print_info "Installing ripgrep via yum..."
sudo yum install -y ripgrep
;;
pacman)
print_info "Installing ripgrep via pacman..."
sudo pacman -S --noconfirm ripgrep
;;
apk)
print_info "Installing ripgrep via apk..."
sudo apk add --no-cache ripgrep
;;
unknown)
print_error "Could not detect package manager"
print_error "Please install ripgrep manually: https://github.com/BurntSushi/ripgrep"
exit 1
;;
esac
else
print_info "✓ ripgrep is already installed"
fi
if [ "$install_lsp" = "true" ]; then
print_info "Installing LSP servers for enhanced IDE features..."
# Install gopls (Go LSP)
if command -v go &> /dev/null && ! command -v gopls &> /dev/null; then
print_info "Installing gopls (Go language server)..."
go install golang.org/x/tools/gopls@latest
fi
# Install typescript-language-server (TypeScript/JavaScript)
if command -v npm &> /dev/null && ! command -v typescript-language-server &> /dev/null; then
print_info "Installing typescript-language-server..."
npm install -g typescript-language-server typescript
fi
# Install pylsp (Python LSP)
if command -v pip3 &> /dev/null && ! python3 -c "import pylsp" 2>/dev/null; then
print_info "Installing python-lsp-server..."
pip3 install python-lsp-server
fi
# Install clangd (C/C++)
if ! command -v clangd &> /dev/null; then
case "$pkg_mgr" in
brew)
print_info "Installing clangd via Homebrew..."
brew install llvm
;;
apt)
print_info "Installing clangd via apt..."
sudo apt-get install -y clangd
;;
yum)
print_info "Installing clangd via yum..."
sudo yum install -y clang-tools-extra
;;
pacman)
print_info "Installing clangd via pacman..."
sudo pacman -S --noconfirm clang
;;
esac
fi
fi
}
get_latest_version() {
local version
version=$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4)
@@ -125,11 +227,34 @@ download_and_install() {
else
print_info "Verifying checksum..."
cd "$tmpdir"
if grep "$archive_name" checksums.txt | sha256sum -c --status; then
print_info "Checksum verification passed"
# Extract expected checksum for our archive
local expected_checksum
expected_checksum=$(grep "$archive_name" checksums.txt | awk '{print $1}')
if [ -z "$expected_checksum" ]; then
print_warn "Checksum not found for $archive_name, skipping verification"
else
print_error "Checksum verification failed"
exit 1
# Calculate actual checksum (use shasum on macOS, sha256sum on Linux)
local actual_checksum
if command -v sha256sum &> /dev/null; then
actual_checksum=$(sha256sum "$archive_name" | awk '{print $1}')
elif command -v shasum &> /dev/null; then
actual_checksum=$(shasum -a 256 "$archive_name" | awk '{print $1}')
else
print_warn "No checksum utility found, skipping verification"
cd - > /dev/null
return
fi
if [ "$expected_checksum" = "$actual_checksum" ]; then
print_info "Checksum verification passed"
else
print_error "Checksum verification failed"
print_error "Expected: $expected_checksum"
print_error "Actual: $actual_checksum"
exit 1
fi
fi
cd - > /dev/null
fi
@@ -183,6 +308,37 @@ main() {
print_info "MCP Filepuff Installation Script"
echo ""
# Parse command line arguments
local install_lsp="false"
local skip_prereqs="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--with-lsp)
install_lsp="true"
shift
;;
--skip-prereqs)
skip_prereqs="true"
shift
;;
--help)
echo "Usage: install.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --with-lsp Install LSP servers (gopls, typescript-language-server, pylsp, clangd)"
echo " --skip-prereqs Skip prerequisite installation (ripgrep, LSP servers)"
echo " --help Show this help message"
exit 0
;;
*)
print_error "Unknown option: $1"
print_error "Use --help for usage information"
exit 1
;;
esac
done
# Check dependencies
check_dependencies
@@ -191,6 +347,18 @@ main() {
platform=$(detect_platform)
print_info "Detected platform: $platform"
# Detect package manager
local pkg_mgr
pkg_mgr=$(detect_package_manager)
print_info "Detected package manager: $pkg_mgr"
echo ""
# Install prerequisites unless skipped
if [ "$skip_prereqs" != "true" ]; then
install_prerequisites "$pkg_mgr" "$install_lsp"
echo ""
fi
# Get latest version
local version
version=$(get_latest_version)
@@ -209,6 +377,11 @@ main() {
echo ""
print_info "To get started, run: $BINARY_NAME --help"
if [ "$install_lsp" != "true" ]; then
echo ""
print_info "Tip: Run with --with-lsp to install LSP servers for enhanced IDE features"
fi
}
# Run main function
+65
View File
@@ -0,0 +1,65 @@
/// A simple point in 2D space.
struct Point {
x: f64,
y: f64,
}
/// Represents different shapes.
enum Shape {
Circle(f64),
Rectangle(f64, f64),
Triangle(f64, f64, f64),
}
/// A trait for objects that can be drawn.
trait Drawable {
fn draw(&self);
fn area(&self) -> f64;
}
impl Drawable for Shape {
fn draw(&self) {
println!("Drawing shape");
}
fn area(&self) -> f64 {
0.0
}
}
impl Point {
/// Creates a new point.
fn new(x: f64, y: f64) -> Self {
Point { x, y }
}
fn distance(&self, other: &Point) -> f64 {
((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt()
}
}
/// A type alias for a list of points.
type PointList = Vec<Point>;
/// The maximum number of points allowed.
const MAX_POINTS: usize = 1000;
static ORIGIN: Point = Point { x: 0.0, y: 0.0 };
/// A helper macro for creating points.
macro_rules! point {
($x:expr, $y:expr) => {
Point::new($x, $y)
};
}
mod geometry {
pub fn hello() {
println!("Hello from geometry");
}
}
fn main() {
let p = point!(1.0, 2.0);
println!("Point: ({}, {})", p.x, p.y);
}
+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