Compare commits

...
68 Commits
Author SHA1 Message Date
github-actions[bot]andGitHub f62b7e0019 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-12 03:24:21 +00:00
github-actions[bot]andGitHub e98ec6032c chore(deps): update module github.com/mark3labs/mcp-go to v1.2.0
Automated dependency update. All tests passed.
2026-10-11 03:18:49 +00:00
lukaszraczylo 5c9b0f967f chore(brew): publish mcp-filepuff cask to the tap 2026-10-03 23:12:15 +01:00
lukaszraczylo cefd6ba7b1 ci(deps): update dependencies with Renovate [skip ci] 2026-10-03 18:44:28 +01:00
lukaszraczyloandGitHub d82479c02e Update go.mod and go.sum (#43) 2026-09-30 04:13:22 +01:00
lukaszraczylo f6ba02e892 ci(lint): migrate golangci config to the v2 schema
The config used v1 keys under a v2 header, so golangci-lint config verify
rejected it and the Lint job failed. This change moves the exclusions to
linters.exclusions and the output order to output.sort-order.

The old linter settings never took effect. Enforcing them reports 62 issues
in the current code. Only the staticcheck setting stays, because it reports
none. The gocyclo, errcheck and govet settings need a separate cleanup.
2026-09-30 01:42:00 +01:00
lukaszraczylo 2344b38d59 ci: build with Go 1.26 to match go.mod
The go.mod file requires Go 1.26.0. The release job runs Go 1.25 with
GOTOOLCHAIN=local, so it fails before it runs any test. The lint and
security jobs use the same pin. This change sets all three to 1.26.
2026-09-30 01:35:43 +01:00
lukaszraczylo 2158228ddb perf(tools): lower default result sizes to cut response tokens
The default limits let broad queries flood the model context. This
change sets smaller defaults. An explicit parameter still overrides each one.

- file_search: max_results 1000 to 50, context_lines 2 to 0.
- ast_query: max_results 100 to 50.
- ast_query compact output omits the node type unless verbose is set.

On a fixed seven-call suite, response size fell from 198529 to 61960 bytes.
2026-09-30 00:20:19 +01:00
lukaszraczyloandGitHub f4f5135ac2 Update go.mod and go.sum (#42) 2026-09-28 04:15:14 +01:00
lukaszraczyloandGitHub 964e987776 Update go.mod and go.sum (#41) 2026-09-24 04:11:15 +01:00
lukaszraczyloandGitHub a7fde76871 Update go.mod and go.sum (#40) 2026-09-16 04:11:14 +01:00
lukaszraczyloandGitHub ed8611d07b Update go.mod and go.sum (#39) 2026-09-09 04:11:28 +01:00
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
54 changed files with 2321 additions and 609 deletions
+7 -7
View File
@@ -1,4 +1,4 @@
name: Autoupdate go.mod and go.sum
name: Update dependencies
on:
workflow_dispatch:
@@ -6,16 +6,16 @@ on:
- cron: "0 3 * * *"
permissions:
contents: write
actions: write
contents: write
issues: write
pull-requests: write
security-events: write
jobs:
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
update:
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
with:
go-version: ">=1.24"
release-workflow: "release.yaml"
node: false
release-workflow: release.yaml
lfs: true
secrets: inherit
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: Run golangci-lint
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
with:
go-version: "1.25"
go-version: "1.26"
rolling-release-tag: "v1"
platforms: >-
[
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: Install govulncheck
+11 -17
View File
@@ -2,7 +2,7 @@ version: "2"
run:
timeout: 5m
go: '1.25'
go: '1.26'
linters:
enable:
@@ -13,23 +13,11 @@ linters:
- 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
settings:
staticcheck:
checks: ["all"]
issues:
exclude-rules:
exclusions:
rules:
# Exclude some linters from running on tests
- path: _test\.go
linters:
@@ -37,8 +25,14 @@ issues:
- errcheck
- gosec
formatters:
enable:
- gofmt # Check formatting
issues:
max-issues-per-linter: 0
max-same-issues: 0
output:
sort-results: true
sort-order:
- file
+18
View File
@@ -95,6 +95,24 @@ release:
```
homebrew_casks:
- name: mcp-filepuff
repository:
owner: lukaszraczylo
name: homebrew-taps
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Casks
homepage: https://github.com/lukaszraczylo/filepuff-mcp
description: "AST-aware file operations and LSP integration MCP server for Claude Code"
license: MIT
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "#{staged_path}/mcp-filepuff"]
end
dockers:
- image_templates:
- "ghcr.io/lukaszraczylo/filepuff-mcp:{{ .Tag }}"
+2 -2
View File
@@ -283,7 +283,7 @@ Search for text patterns in files using ripgrep.
- `file_types`: File types to search (e.g., ["go", "ts", "py"])
- `ignore_case`: Case insensitive search
- `regex`: Treat pattern as regex (default: true)
- `context_lines`: Number of context lines around matches (default: 2)
- `context_lines`: Number of context lines around matches (default: 0)
- `max_results`: Maximum number of results to return
---
@@ -352,7 +352,7 @@ Search for AST patterns in code files using structural pattern matching.
- `name_matches`: Regex pattern to filter by name
- `name_exact`: Exact name to match
- `kind_in`: Node types to match (e.g., function_declaration)
- `max_results`: Maximum number of results (default: 100)
- `max_results`: Maximum number of results (default: 50)
**Examples**:
```json
+9 -9
View File
@@ -211,7 +211,7 @@ func GenerateMarkdown() string {
var sb strings.Builder
sb.WriteString("# MCP Filepuff API Reference\n\n")
sb.WriteString(fmt.Sprintf("> Auto-generated on %s\n\n", time.Now().Format("2006-01-02")))
fmt.Fprintf(&sb, "> Auto-generated on %s\n\n", time.Now().Format("2006-01-02"))
sb.WriteString("This document provides detailed API documentation for all MCP tools available in filepuff.\n\n")
// Table of Contents
@@ -228,9 +228,9 @@ func GenerateMarkdown() string {
}
for _, cat := range categoryOrder {
sb.WriteString(fmt.Sprintf("### %s\n", cat))
fmt.Fprintf(&sb, "### %s\n", cat)
for _, tool := range categories[cat] {
sb.WriteString(fmt.Sprintf("- [`%s`](#%s)\n", tool.Name, tool.Name))
fmt.Fprintf(&sb, "- [`%s`](#%s)\n", tool.Name, tool.Name)
}
sb.WriteString("\n")
}
@@ -240,11 +240,11 @@ func GenerateMarkdown() string {
sb.WriteString("## Tool Reference\n\n")
for _, cat := range categoryOrder {
sb.WriteString(fmt.Sprintf("### %s\n\n", cat))
fmt.Fprintf(&sb, "### %s\n\n", cat)
for _, tool := range categories[cat] {
sb.WriteString(fmt.Sprintf("#### `%s`\n\n", tool.Name))
sb.WriteString(fmt.Sprintf("%s\n\n", tool.Description))
fmt.Fprintf(&sb, "#### `%s`\n\n", tool.Name)
fmt.Fprintf(&sb, "%s\n\n", tool.Description)
// Read-only indicator
if tool.ReadOnly {
@@ -263,8 +263,8 @@ func GenerateMarkdown() string {
if param.Required {
required = "**Yes**"
}
sb.WriteString(fmt.Sprintf("| `%s` | `%s` | %s | %s |\n",
param.Name, param.Type, required, param.Description))
fmt.Fprintf(&sb, "| `%s` | `%s` | %s | %s |\n",
param.Name, param.Type, required, param.Description)
}
sb.WriteString("\n")
} else {
@@ -285,7 +285,7 @@ func GenerateMarkdown() string {
if len(tool.Notes) > 0 {
sb.WriteString("**Notes:**\n\n")
for _, note := range tool.Notes {
sb.WriteString(fmt.Sprintf("- %s\n", note))
fmt.Fprintf(&sb, "- %s\n", note)
}
sb.WriteString("\n")
}
+2 -2
View File
@@ -70,7 +70,7 @@ Search for text patterns in files using ripgrep. Supports regex patterns, file t
| `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) |
| `context_lines` | `number` | No | Number of context lines around matches (default: 0) |
| `max_results` | `number` | No | Maximum number of results to return |
**Examples:**
@@ -153,7 +153,7 @@ Search for AST patterns in code files. Use code patterns with $VAR placeholders
| `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) |
| `max_results` | `number` | No | Maximum number of results to return (default: 50) |
**Examples:**
+5 -5
View File
@@ -1,12 +1,12 @@
module github.com/lukaszraczylo/mcp-filepuff
go 1.25.5
go 1.26.0
require (
github.com/cespare/xxhash/v2 v2.3.0
github.com/goccy/go-json v0.10.6
github.com/goccy/go-json v0.11.2
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mark3labs/mcp-go v0.54.1
github.com/mark3labs/mcp-go v1.2.1
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
@@ -15,8 +15,8 @@ require (
require (
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/text v0.43.0 // indirect
)
+10 -8
View File
@@ -7,8 +7,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK
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.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
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=
@@ -24,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/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mark3labs/mcp-go v1.2.1 h1:gd8VD3feMAVycwAM0x1nhv42Q1oLLjfOyowoUzHULB8=
github.com/mark3labs/mcp-go v1.2.1/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.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.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
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=
@@ -44,8 +44,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+12 -2
View File
@@ -33,7 +33,7 @@ const (
DefaultSearchTimeout = 30 * time.Second
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxSearchResults = 1000
DefaultMaxSearchResults = 50
DefaultMaxEditSize = 100 * 1024 // 100 KB
DefaultResourceLinkThresholdBytes = 64 * 1024 // 64 KiB
)
@@ -84,14 +84,24 @@ func Load(workspaceRoot string) (*Config, error) {
// Restore WorkspaceRoot — config file must not override path guards.
cfg.WorkspaceRoot = savedRoot
// Clamp size limits to prevent config file from requesting excessive memory.
// Clamp size/count limits so the config file cannot request excessive memory.
const maxAllowedSize int64 = 100 * 1024 * 1024 // 100 MB
const 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()
+29
View File
@@ -183,6 +183,35 @@ func TestLoadWithConfigFile(t *testing.T) {
}
}
// A config file requesting excessive size/count limits must be clamped so it
// cannot force the server into unbounded memory use.
func TestLoadClampsExcessiveLimits(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
configContent := `{
"max_file_size": 999999999999,
"max_parse_size": 999999999999,
"max_edit_size": 999999999999,
"max_search_results": 999999999
}`
if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
const maxSize int64 = 100 * 1024 * 1024
if cfg.MaxFileSize > maxSize || cfg.MaxParseSize > maxSize || cfg.MaxEditSize > maxSize {
t.Errorf("size limits not clamped: file=%d parse=%d edit=%d", cfg.MaxFileSize, cfg.MaxParseSize, cfg.MaxEditSize)
}
if cfg.MaxSearchResults > 100000 {
t.Errorf("MaxSearchResults not clamped: %d", cfg.MaxSearchResults)
}
}
// TestValidate tests the Validate method with various inputs.
func TestValidate(t *testing.T) {
tests := []struct {
+2
View File
@@ -26,6 +26,8 @@ type payload struct {
// 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)
}
+57 -53
View File
@@ -63,17 +63,46 @@ 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,
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() {
@@ -179,14 +208,7 @@ func (e *Engine) performASTEdit(ctx context.Context, edit *ASTEdit, apply bool)
// Apply changes if requested
if apply {
// Preserve original file permissions
fileInfo, err := os.Stat(edit.File)
perm := os.FileMode(0o600) // default fallback
if err == nil {
perm = fileInfo.Mode().Perm()
}
if err := os.WriteFile(edit.File, newContent, perm); err != nil {
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
structuredErr := errors.NewFileNotWritableError(edit.File, err)
return &EditResult{
Success: false,
@@ -236,14 +258,7 @@ func (e *Engine) performTextEdit(_ context.Context, edit *ASTEdit, apply bool) (
// Apply changes if requested
if apply {
// Preserve original file permissions
fileInfo, err := os.Stat(edit.File)
perm := os.FileMode(0o600) // default fallback
if err == nil {
perm = fileInfo.Mode().Perm()
}
if err := os.WriteFile(edit.File, newContent, perm); err != nil {
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
structuredErr := errors.NewFileNotWritableError(edit.File, err)
return &EditResult{
Success: false,
@@ -270,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
@@ -638,16 +657,19 @@ func flattenDiffLines(diffs []diffmatchpatch.Diff) (lines []diffLine, maxOldN, m
// 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:
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})
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, newN: newLine})
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
newLine++
}
}
@@ -676,38 +698,31 @@ func diffHunkRanges(lines []diffLine) []indexRange {
}
// hunkBounds computes the unified-diff hunk header line numbers and counts for
// lines[start:end+1]. newStart/oldStart for one-sided lines are approximate.
// 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++ {
l := lines[i]
switch l.op {
switch lines[i].op {
case diffmatchpatch.DiffEqual:
if oldCount == 0 {
oldStart = l.oldN
}
if newCount == 0 {
newStart = l.newN
}
oldCount++
newCount++
case diffmatchpatch.DiffDelete:
if oldCount == 0 {
oldStart = l.oldN
}
if newCount == 0 {
newStart = l.oldN // approximate
}
oldCount++
case diffmatchpatch.DiffInsert:
if newCount == 0 {
newStart = l.newN
}
if oldCount == 0 {
oldStart = l.newN // approximate
}
newCount++
}
}
if oldCount == 0 {
oldStart = max(0, oldStart-1)
}
if newCount == 0 {
newStart = max(0, newStart-1)
}
return
}
@@ -864,16 +879,5 @@ func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
// 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)] + "..."
}
+222
View File
@@ -101,6 +101,22 @@ func TestValidateEdit(t *testing.T) {
}
}
// A replace with empty new_content should point the caller at operation=delete.
func TestValidateReplaceEmptySuggestsDelete(t *testing.T) {
e := NewEngine(parser.NewRegistry())
err := e.validateASTEdit(&ASTEdit{
File: "test.go",
Operation: EditReplace,
Selector: ASTSelector{Kind: "function_declaration"},
})
if err == nil {
t.Fatal("expected error for replace with empty new_content")
}
if !strings.Contains(err.Error(), "operation=delete") {
t.Errorf("error should suggest operation=delete, got: %v", err)
}
}
func TestResolveSelector(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
@@ -353,6 +369,112 @@ func Hello() {
}
}
// TestApplyRevalidatesPathBeforeWrite verifies the write-time admission re-check:
// an Engine whose validator rejects the target must refuse the write and leave
// the file untouched (the TOCTOU guard against a symlink swapped in after the
// handler's original IsPathAllowed check).
func TestApplyRevalidatesPathBeforeWrite(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.go")
original := "package main\n\nfunc Hello() {}\n"
if err := os.WriteFile(tmpFile, []byte(original), 0o600); err != nil {
t.Fatal(err)
}
// Validator denies the path (as IsPathAllowed would for a path that now
// resolves outside the workspace root).
e := NewEngineWithValidator(registry, func(string) bool { return false })
result, err := e.Apply(context.Background(), &ASTEdit{
File: tmpFile,
Operation: EditReplace,
Selector: ASTSelector{Kind: "function_declaration"},
NewContent: "func Evil() {}",
})
if err != nil {
t.Fatalf("apply returned transport error: %v", err)
}
if result.Success || result.Applied {
t.Errorf("rejected-path edit must not apply (Success=%v Applied=%v)", result.Success, result.Applied)
}
if got, _ := os.ReadFile(tmpFile); string(got) != original {
t.Errorf("file must be unchanged when path validation fails; got:\n%s", got)
}
}
// TestApplyPreservesFileMode verifies an edit keeps the file's existing
// permission bits rather than resetting them.
func TestApplyPreservesFileMode(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "script.go")
if err := os.WriteFile(tmpFile, []byte("package main\n\nfunc Hello() {}\n"), 0o755); err != nil {
t.Fatal(err)
}
result, err := e.Apply(context.Background(), &ASTEdit{
File: tmpFile,
Operation: EditReplace,
Selector: ASTSelector{Kind: "function_declaration"},
NewContent: "func Hi() {}",
})
if err != nil || !result.Success {
t.Fatalf("apply failed: err=%v result=%+v", err, result)
}
info, err := os.Stat(tmpFile)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o755 {
t.Errorf("file mode not preserved: got %o, want 0755", perm)
}
}
// An edit on a CRLF file must normalize inserted LF content to CRLF so the file
// keeps a single, consistent line-ending convention (a documented guarantee).
func TestApplyPreservesCRLF(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "crlf.go")
content := "package main\r\n\r\nfunc Hello() {\r\n\tprintln(\"hi\")\r\n}\r\n"
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("write: %v", err)
}
_, err := e.Apply(context.Background(), &ASTEdit{
File: tmpFile,
Operation: EditReplace,
Selector: ASTSelector{Kind: "function_declaration"},
NewContent: "func Bye() {\n\tprintln(\"bye\")\n}", // LF newlines on input
})
if err != nil {
t.Fatalf("apply failed: %v", err)
}
out, err := os.ReadFile(tmpFile)
if err != nil {
t.Fatalf("read: %v", err)
}
s := string(out)
if !strings.Contains(s, "func Bye()") {
t.Fatalf("edit not applied:\n%q", s)
}
for i := 0; i < len(s); i++ {
if s[i] == '\n' && (i == 0 || s[i-1] != '\r') {
t.Fatalf("bare LF at offset %d — CRLF not preserved:\n%q", i, s)
}
}
}
func TestGenerateDiff(t *testing.T) {
original := "line1\nline2\nline3"
modified := "line1\nmodified\nline3"
@@ -411,6 +533,29 @@ func TestGenerateDiffLineLevelAccuracy(t *testing.T) {
}
}
// Hunk @@ headers must be correct at file boundaries (previously approximate).
func TestGenerateDiffHunkHeaderBounds(t *testing.T) {
e := &Engine{dmp: diffmatchpatch.New()}
tests := []struct {
name string
original string
modified string
wantHdr string
}{
{name: "insert into empty file", original: "", modified: "X\nY\n", wantHdr: "@@ -0,0 +1,2 @@"},
{name: "delete entire file", original: "a\nb\n", modified: "", wantHdr: "@@ -1,2 +0,0 @@"},
{name: "mid-file change keeps 1-based start", original: "l1\nl2\nl3\nl4\nl5\n", modified: "l1\nl2\nCHANGED\nl4\nl5\n", wantHdr: "@@ -1,5 +1,5 @@"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff := e.generateDiff(tt.original, tt.modified, "f.txt")
if !strings.Contains(diff, tt.wantHdr) {
t.Errorf("expected hunk header %q, got:\n%s", tt.wantHdr, diff)
}
})
}
}
func TestGenerateDiffNoPhantomChanges(t *testing.T) {
// Regression test: replacing a line range should not produce phantom
// +/- lines for unchanged code after the edit region.
@@ -1673,3 +1818,80 @@ func TestRegressionLineMergeDelete(t *testing.T) {
})
}
}
// TestFindExactTextIndexAndOverlap pins selector_index resolution for literal
// text matches, including overlapping matches and the out-of-range guard — the
// range these return is the span that gets mutated, so wrong indices = wrong-
// location writes.
func TestFindExactTextIndexAndOverlap(t *testing.T) {
e := NewEngine(parser.NewRegistry())
content := []byte("aaaa") // "aa" matches at 0,1,2 (overlapping)
for i, want := range []struct{ start, end int }{{0, 2}, {1, 3}, {2, 4}} {
start, end, err := e.findExactText(content, "aa", i)
if err != nil {
t.Fatalf("index %d: unexpected error: %v", i, err)
}
if start != want.start || end != want.end {
t.Errorf("index %d: got [%d,%d), want [%d,%d)", i, start, end, want.start, want.end)
}
}
if _, _, err := e.findExactText(content, "aa", 3); err == nil {
t.Error("index 3 (only 3 matches) must be out-of-range error")
}
if _, _, err := e.findExactText(content, "zz", 0); err == nil {
t.Error("missing text must error")
}
if _, _, err := e.findExactText(content, "", 0); err == nil {
t.Error("empty text selector must error")
}
}
// TestFindRegexPatternIndexAndError pins selector_index and error paths for regex
// selection (non-overlapping matches via FindAllIndex).
func TestFindRegexPatternIndexAndError(t *testing.T) {
e := NewEngine(parser.NewRegistry())
content := []byte("a1b2c3") // [0-9] matches at 1,3,5
for i, want := range []struct{ start, end int }{{1, 2}, {3, 4}, {5, 6}} {
start, end, err := e.findRegexPattern(content, "[0-9]", i)
if err != nil {
t.Fatalf("index %d: unexpected error: %v", i, err)
}
if start != want.start || end != want.end {
t.Errorf("index %d: got [%d,%d), want [%d,%d)", i, start, end, want.start, want.end)
}
}
if _, _, err := e.findRegexPattern(content, "[0-9]", 3); err == nil {
t.Error("index 3 (only 3 matches) must be out-of-range error")
}
if _, _, err := e.findRegexPattern(content, "zzz", 0); err == nil {
t.Error("non-matching pattern must error")
}
if _, _, err := e.findRegexPattern(content, "[unclosed", 0); err == nil {
t.Error("invalid regex must error")
}
}
// TestSpliceContentCRLFNoBareLF verifies the CRLF pullback: when an edit range
// ends between the \r and \n of a terminator, the \r\n must stay intact in the
// tail rather than being split into a bare LF (which would corrupt the file's
// line-ending convention).
func TestSpliceContentCRLFNoBareLF(t *testing.T) {
// Replace [0,4) which ends on the \n of "foo\r\n" (index 4, preceded by \r@3).
got, err := spliceContent(EditReplace, []byte("foo\r\nbar\r\n"), 0, 4, "X", "\r\n")
if err != nil {
t.Fatalf("splice: %v", err)
}
if string(got) != "X\r\nbar\r\n" {
t.Fatalf("pullback failed: got %q, want %q", got, "X\r\nbar\r\n")
}
// No \n may appear without a preceding \r.
for i, b := range got {
if b == '\n' && (i == 0 || got[i-1] != '\r') {
t.Errorf("bare LF at index %d in %q", i, got)
}
}
}
+62 -4
View File
@@ -22,6 +22,10 @@ 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
@@ -35,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
}
@@ -121,6 +127,12 @@ func NewClient(cmd *exec.Cmd) (*Client, error) {
// 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 {
@@ -129,14 +141,35 @@ func (c *Client) drainStderr() {
return
default:
}
// Read and discard stderr output
_, err := c.stderr.Read(buf)
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()
@@ -271,7 +304,12 @@ func (c *Client) send(msg interface{}) error {
// 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)
@@ -315,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
@@ -340,13 +385,19 @@ func (c *Client) drainPending() {
defer c.mu.Unlock()
for id, ch := range c.pending {
ch <- &Response{
// 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)
}
@@ -358,3 +409,10 @@ func (c *Client) IsRunning() bool {
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)
}
})
}
}
+67 -11
View File
@@ -128,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()
@@ -141,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 {
@@ -212,10 +224,16 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
// Initialize server
if err := m.initializeServer(ctx, newSrv); err != nil {
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
@@ -358,19 +376,57 @@ 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
}
// 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.
func (m *Manager) References(ctx context.Context, file string, line, col int, includeDeclaration bool) ([]Location, error) {
lang := protocol.DetectLanguage(file)
+22 -17
View File
@@ -33,8 +33,13 @@ func (c *Counter) Inc() {
c.value.Add(1)
}
// Add adds the given value to the counter.
// Add adds the given value to the counter. Counters are monotonic, so a
// negative delta (which would corrupt rate()-style calculations downstream) is
// ignored rather than applied.
func (c *Counter) Add(delta int64) {
if delta < 0 {
return
}
c.value.Add(delta)
}
@@ -278,27 +283,27 @@ func (r *Registry) Expose() string {
// Export counters
for _, c := range r.counters {
if c.help != "" {
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", c.name, c.help))
fmt.Fprintf(&sb, "# HELP %s %s\n", c.name, c.help)
}
sb.WriteString(fmt.Sprintf("# TYPE %s counter\n", c.name))
sb.WriteString(fmt.Sprintf("%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load()))
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 != "" {
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", g.name, g.help))
fmt.Fprintf(&sb, "# HELP %s %s\n", g.name, g.help)
}
sb.WriteString(fmt.Sprintf("# TYPE %s gauge\n", g.name))
sb.WriteString(fmt.Sprintf("%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load()))
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 != "" {
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", h.name, h.help))
fmt.Fprintf(&sb, "# HELP %s %s\n", h.name, h.help)
}
sb.WriteString(fmt.Sprintf("# TYPE %s histogram\n", h.name))
fmt.Fprintf(&sb, "# TYPE %s histogram\n", h.name)
// Cumulative bucket counts
var cumulative int64
@@ -306,26 +311,26 @@ func (r *Registry) Expose() string {
cumulative += h.counts[i].Load()
labelStr := formatLabels(h.labels)
if labelStr == "" {
sb.WriteString(fmt.Sprintf("%s_bucket{le=\"%g\"} %d\n", h.name, bound, cumulative))
fmt.Fprintf(&sb, "%s_bucket{le=\"%g\"} %d\n", h.name, bound, cumulative)
} else {
// Insert le label into existing labels
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name,
strings.Replace(labelStr, "}", fmt.Sprintf(",le=\"%g\"}", bound), 1), cumulative))
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 == "" {
sb.WriteString(fmt.Sprintf("%s_bucket{le=\"+Inf\"} %d\n", h.name, cumulative))
fmt.Fprintf(&sb, "%s_bucket{le=\"+Inf\"} %d\n", h.name, cumulative)
} else {
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name,
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative))
fmt.Fprintf(&sb, "%s_bucket%s %d\n", h.name,
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative)
}
// Sum and count
sb.WriteString(fmt.Sprintf("%s_sum%s %g\n", h.name, formatLabels(h.labels), h.Sum()))
sb.WriteString(fmt.Sprintf("%s_count%s %d\n", h.name, formatLabels(h.labels), h.count.Load()))
fmt.Fprintf(&sb, "%s_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()
+10
View File
@@ -71,6 +71,16 @@ func TestCounter(t *testing.T) {
}
}
// Counters are monotonic: a negative Add must be ignored, not applied.
func TestCounterRejectsNegativeAdd(t *testing.T) {
c := NewCounter("mono_counter", "test", nil)
c.Add(5)
c.Add(-3)
if got := c.Value(); got != 5 {
t.Errorf("negative Add should be ignored: Value() = %d, want 5", got)
}
}
func TestCounterConcurrency(t *testing.T) {
c := NewCounter("concurrent_counter", "test", nil)
var wg sync.WaitGroup
+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 -2
View File
@@ -3,6 +3,7 @@ package parser
import (
"regexp"
"sort"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
@@ -530,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())
+8 -4
View File
@@ -48,10 +48,13 @@ type Registry struct {
}
// 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.
@@ -202,11 +205,10 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
hash := fmt.Sprintf("%s:%016x", string(lang), xxhash.Sum64(content))
if cached, ok := r.cache.Get(hash); ok && cached.Language == lang {
r.cacheHits.Add(1)
errors := extractErrors(cached.Tree.RootNode(), content)
return &ParseResult{
Tree: cached.Tree,
Language: lang,
Errors: errors,
Errors: cached.Errors,
Content: content,
}, nil
}
@@ -242,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{
+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
+39 -11
View File
@@ -36,22 +36,23 @@ func SkeletonFile(ctx context.Context, reg *Registry, filename string, content [
default:
// TODO: skeleton for c, cpp, elixir, html, vue — fall back to symbols_only
syms := ExtractSymbols(result.Tree, content, lang, filename)
return renderSymbolsOnly(syms, filename, lang, content), false, nil
return renderSymbolsOnly(syms, lang), false, nil
}
}
// renderSymbolsOnly renders a simple symbol list (fallback for unsupported languages).
func renderSymbolsOnly(syms []protocol.Symbol, _ string, lang protocol.Language, content []byte) string {
lines := strings.Split(string(content), "\n")
// 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
sb.WriteString(fmt.Sprintf("// skeleton unavailable for %s — symbol list only\n", lang))
fmt.Fprintf(&sb, "symbols only (no skeleton for %s):\n", lang)
for _, s := range syms {
sb.WriteString(fmt.Sprintf("// %s %s (line %d)\n", s.Kind, s.Name, s.Location.Line))
fmt.Fprintf(&sb, "%s %s :%d\n", s.Kind, s.Name, s.Location.Line)
if s.Doc != "" {
sb.WriteString(fmt.Sprintf("// doc: %s\n", s.Doc))
}
if s.Location.Line >= 1 && s.Location.Line <= len(lines) {
sb.WriteString(lines[s.Location.Line-1] + " { ... }\n")
fmt.Fprintf(&sb, " doc: %s\n", s.Doc)
}
}
return sb.String()
@@ -216,7 +217,34 @@ func skeletonPythonNode(node *sitter.Node, content []byte, sb *strings.Builder,
skeletonPythonNode(child, content, sb, indent)
}
return
case "function_definition", "decorated_definition":
case "decorated_definition":
// Emit the decorator line(s), then render the wrapped definition so its
// signature (def/class) is preserved — taking lines[0] of the whole node
// would keep only the first decorator and drop the signature entirely.
var def *sitter.Node
for i := 0; i < int(node.NamedChildCount()); i++ {
if child := node.NamedChild(i); child != nil {
if t := child.Type(); t == "function_definition" || t == "class_definition" {
def = child
break
}
}
}
if def != nil {
decText := strings.TrimRight(string(content[node.StartByte():def.StartByte()]), "\n")
for _, dl := range strings.Split(decText, "\n") {
if strings.TrimSpace(dl) == "" {
continue
}
sb.WriteString(indent)
sb.WriteString(dl)
sb.WriteString("\n")
}
skeletonPythonNode(def, content, sb, indent)
return
}
fallthrough
case "function_definition":
nodeText := string(content[node.StartByte():node.EndByte()])
lines := strings.SplitN(nodeText, "\n", 2)
sb.WriteString(indent)
+48
View File
@@ -0,0 +1,48 @@
package parser
import (
"context"
"strings"
"testing"
)
// A decorated Python function must keep BOTH its decorator(s) and its def
// signature in the skeleton — previously only the first decorator survived and
// the signature was dropped.
func TestSkeletonPythonDecoratedFunction(t *testing.T) {
r := NewRegistry()
defer r.Close()
src := "@app.route(\"/\")\n" +
"def home():\n" +
" return \"hi\"\n"
out, _, err := SkeletonFile(context.Background(), r, "app.py", []byte(src))
if err != nil {
t.Fatalf("SkeletonFile error: %v", err)
}
if !strings.Contains(out, "@app.route(\"/\")") {
t.Errorf("skeleton lost decorator, got:\n%s", out)
}
if !strings.Contains(out, "def home():") {
t.Errorf("skeleton lost def signature, got:\n%s", out)
}
if strings.Contains(out, "return \"hi\"") {
t.Errorf("skeleton should elide the body, got:\n%s", out)
}
}
// A plain (undecorated) Python function still renders its signature + body stub.
func TestSkeletonPythonPlainFunction(t *testing.T) {
r := NewRegistry()
defer r.Close()
src := "def add(a, b):\n return a + b\n"
out, _, err := SkeletonFile(context.Background(), r, "m.py", []byte(src))
if err != nil {
t.Fatalf("SkeletonFile error: %v", err)
}
if !strings.Contains(out, "def add(a, b):") {
t.Errorf("skeleton lost signature, got:\n%s", out)
}
}
+103 -15
View File
@@ -59,20 +59,60 @@ func StripContent(content string, flags []StripFlag, lang protocol.Language) Str
return StripResult{Content: content, Stripped: stripped}
}
// stripLicense removes a leading block comment that looks like a license header.
// A comment qualifies if it contains "copyright", "license", or "spdx-license-identifier" (case-insensitive).
// 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) {
trimmed := strings.TrimLeft(content, " \t\n\r")
prefix, rest := peelLicensePrefix(content)
next, removed := stripLeadingLicenseComment(rest)
if !removed {
return content, false
}
return prefix + next, true
}
// stripLeadingLicenseComment removes a license header at the top of s, handling
// C-style /* */ blocks, contiguous // line comments, and contiguous # comments.
func stripLeadingLicenseComment(s string) (string, bool) {
trimmed := strings.TrimLeft(s, " \t\n\r")
// C-style block comment at top
if strings.HasPrefix(trimmed, "/*") {
end := strings.Index(trimmed, "*/")
if end >= 0 {
candidate := trimmed[:end+2]
lower := strings.ToLower(candidate)
if strings.Contains(lower, "copyright") ||
strings.Contains(lower, "license") ||
strings.Contains(lower, "spdx-license-identifier") {
if containsLicenseKeyword(strings.ToLower(candidate)) {
rest := trimmed[end+2:]
// Consume trailing newline(s)
rest = strings.TrimLeft(rest, "\r\n")
@@ -81,6 +121,24 @@ func stripLicense(content string) (string, bool) {
}
}
// "//" 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).
@@ -95,15 +153,12 @@ func stripLicense(content string) (string, bool) {
rest = lines[i:]
break
}
lower := strings.ToLower(strings.Join(commentLines, "\n"))
if strings.Contains(lower, "copyright") ||
strings.Contains(lower, "license") ||
strings.Contains(lower, "spdx-license-identifier") {
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
return strings.Join(rest, "\n"), true
}
}
return content, false
return s, false
}
// stripImports removes top-of-file import blocks, language-specific.
@@ -147,7 +202,10 @@ func stripGoImports(content string) (string, bool) {
}
continue
}
if strings.HasPrefix(trimLine, `import "`) || strings.HasPrefix(trimLine, "import `") {
// Single import spec in any form: import "path", import `path`,
// import alias "path", import _ "path", import . "path".
if strings.HasPrefix(trimLine, "import ") &&
(strings.Contains(trimLine, `"`) || strings.Contains(trimLine, "`")) {
removed = true
i++
continue
@@ -161,15 +219,45 @@ func stripGoImports(content string) (string, bool) {
return strings.Join(out, "\n"), true
}
// stripTSImports removes TypeScript/JavaScript "import ... from ..." and "require(...)" lines.
// tsImportNeedsContinuation reports whether an import line (known to start an
// import) continues onto following lines. A multi-line named import opens a
// brace and only reaches its module specifier ("from '...'") on a later line.
func tsImportNeedsContinuation(line string) bool {
if strings.Contains(line, "from ") || strings.HasSuffix(line, ";") {
return false
}
// Side-effect import (import '...' / import "...") is single-line.
rest := strings.TrimSpace(strings.TrimPrefix(line, "import"))
if strings.HasPrefix(rest, "'") || strings.HasPrefix(rest, `"`) {
return false
}
return true
}
// stripTSImports removes TypeScript/JavaScript "import ... from ..." (including
// multi-line named imports) and destructured require() lines.
func stripTSImports(content string) (string, bool) {
lines := strings.Split(content, "\n")
var out []string
removed := false
inMulti := false
for _, l := range lines {
trimLine := strings.TrimSpace(l)
if strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "const {") && strings.Contains(trimLine, "require(") {
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)
+53
View File
@@ -1,6 +1,7 @@
package parser
import (
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
@@ -45,3 +46,55 @@ func TestStripLicensePythonPreservesSeparatorBlank(t *testing.T) {
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) }
+192 -48
View File
@@ -1,6 +1,8 @@
package parser
import (
"strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
sitter "github.com/smacker/go-tree-sitter"
)
@@ -35,6 +37,28 @@ func ExtractSymbols(tree *sitter.Tree, content []byte, lang protocol.Language, f
}
// extractGoSymbols extracts symbols from Go code.
// 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
@@ -49,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
@@ -152,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),
})
}
}
@@ -182,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
@@ -386,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),
}
}
@@ -445,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),
}
}
@@ -673,17 +750,52 @@ func extractElixirMacro(n *sitter.Node, content []byte, filename string) *protoc
}
}
// extractElixirStruct extracts a struct definition.
// extractElixirStruct extracts a struct definition. A defstruct has no name of
// its own — it defines the struct of its enclosing module — so name it after
// that module (%ModuleName{}) rather than the useless literal "defstruct".
func extractElixirStruct(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defstruct is typically inside a module, the struct name is the module name
// We just mark this as a struct symbol
name := "defstruct"
if mod := enclosingElixirModuleName(n, content); mod != "" {
name = "%" + mod + "{}"
}
return &protocol.Symbol{
Name: "defstruct",
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
@@ -910,17 +1022,37 @@ func kindMatchesNode(kind protocol.SymbolKind, nodeType string) bool {
return true
}
// 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.
func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
// 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
return nil
}
var candidates []SymbolCandidate
WalkTree(tree.RootNode(), func(n *sitter.Node) bool {
if found {
return false
}
nameNode := n.ChildByFieldName("name")
if nameNode == nil {
return true
@@ -928,22 +1060,34 @@ func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName str
if GetNodeText(nameNode, content) != symbolName {
return true
}
switch n.Type() {
case "function_declaration", "method_declaration", "type_declaration",
"function_definition", "class_definition", "class_declaration",
"interface_declaration", "type_alias_declaration",
"function_item", "struct_item", "enum_item", "trait_item",
"type_item", "const_item", "mod_item":
if !symbolDeclNodeTypes[n.Type()] {
return true
}
if !kindMatchesNode(symbolKind, n.Type()) {
return true // kind mismatch, keep searching
}
r := NodeRange(n, filename)
startLine = r.Start.Line
endLine = r.End.Line
found = true
return false
}
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
return candidates
}
// FindSymbolRange finds a named symbol in the AST and returns its line range (1-indexed, inclusive).
// symbolKind filters by symbol kind (e.g. "function", "struct"); empty string matches any kind.
// Returns (0, 0, false) if the symbol is not found. On ambiguity it returns the first match;
// callers that need to detect ambiguity should use FindSymbolCandidates.
func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
candidates := FindSymbolCandidates(tree, content, filename, symbolName, symbolKind)
if len(candidates) == 0 {
return 0, 0, false
}
return candidates[0].StartLine, candidates[0].EndLine, true
}
+201
View File
@@ -7,6 +7,64 @@ import (
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
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()
+8 -145
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)
}
// 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)
}
// 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)
}
})
}
}
+30
View File
@@ -128,6 +128,33 @@ func TestFormatResultsPaginationCursor(t *testing.T) {
}
}
// FormatResultsWithCursor must emit the supplied cursor footer verbatim in place
// of the bare "[remaining: N]" hint when results are truncated.
func TestFormatResultsWithCursor(t *testing.T) {
results := makeResults(5)
cursor := "[cursor: abc123, remaining: 3]"
out := FormatResultsWithCursor(results, 2, "compact", 0, false, cursor)
if !strings.Contains(out, cursor) {
t.Errorf("expected cursor footer %q, got:\n%s", cursor, out)
}
if strings.Contains(out, "[remaining: 3]\n") {
t.Errorf("bare remaining hint should be replaced by cursor footer, got:\n%s", out)
}
// Empty cursorLine falls back to the bare hint (parity with FormatResultsWithOptions).
fallback := FormatResultsWithCursor(results, 2, "compact", 0, false, "")
if !strings.Contains(fallback, "[remaining: 3]") {
t.Errorf("empty cursorLine should fall back to [remaining: 3], got:\n%s", fallback)
}
// No truncation → no footer at all.
none := FormatResultsWithCursor(makeResults(2), 0, "compact", 0, false, cursor)
if strings.Contains(none, cursor) || strings.Contains(none, "[remaining:") {
t.Errorf("no truncation should emit no footer, got:\n%s", none)
}
}
func TestFormatResultsEmpty(t *testing.T) {
out := FormatResultsWithOptions(nil, 0, "verbose", 0)
if out != "No matches found." {
@@ -159,6 +186,9 @@ func TestFirstLineOf(t *testing.T) {
{"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)
+36 -12
View File
@@ -460,7 +460,24 @@ func FormatResults(results []MatchResult, maxResults int) string {
// 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."
}
@@ -484,27 +501,30 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
var sb strings.Builder
// Emit preamble only when verbose=true is explicitly passed (opt-in, default off).
wantVerbose := len(verbose) > 0 && verbose[0]
if wantVerbose {
sb.WriteString(fmt.Sprintf("Found %d match(es):\n", renderCount))
if verbose {
fmt.Fprintf(&sb, "Found %d match(es):\n", renderCount)
}
switch format {
case "compact":
for i := 0; i < renderCount; i++ {
r := results[i]
firstLine := firstLineOf(r.Text, 80)
if !verbose {
fmt.Fprintf(&sb, "%s:%d %s\n", r.File, r.Location.Line, firstLine)
continue
}
nodeType := "unknown"
if r.Node != nil {
nodeType = r.Node.Type()
}
firstLine := firstLineOf(r.Text, 80)
sb.WriteString(fmt.Sprintf("%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine))
fmt.Fprintf(&sb, "%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)
}
case "location":
for i := 0; i < renderCount; i++ {
r := results[i]
sb.WriteString(fmt.Sprintf("%s:%d\n", r.File, r.Location.Line))
fmt.Fprintf(&sb, "%s:%d\n", r.File, r.Location.Line)
}
default: // "verbose"
@@ -514,7 +534,7 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
if r.Node != nil {
nodeType = r.Node.Type()
}
sb.WriteString(fmt.Sprintf("**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType))
fmt.Fprintf(&sb, "**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType)
// Truncate very long text
text := r.Text
@@ -538,7 +558,7 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
if len(capText) > 50 {
capText = capText[:50] + "..."
}
sb.WriteString(fmt.Sprintf("$%s=%s", name, capText))
fmt.Fprintf(&sb, "$%s=%s", name, capText)
}
sb.WriteString("\n")
}
@@ -547,9 +567,13 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
}
if remaining > 0 {
// Caller must embed the cursor token; we just append the remaining count hint.
// The actual [cursor: ...] line is written by the handler after calling MakeCursor.
sb.WriteString(fmt.Sprintf("[remaining: %d]\n", remaining))
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()
@@ -563,7 +587,7 @@ func firstLineOf(s string, maxLen int) string {
continue
}
if len(line) > maxLen {
return line[:maxLen-1] + "…"
return line[:util.RuneBoundary(line, maxLen-1)] + "…"
}
return line
}
+30 -7
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"
)
@@ -257,6 +258,17 @@ func (s *Searcher) validatePaths(paths []string) error {
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{
@@ -287,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
@@ -389,13 +409,15 @@ func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatO
if opts.Verbose {
totalMatches := len(results.Results)
fileCount := len(fileResults)
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
fmt.Fprintf(&sb, "Found %d matches in %d files", totalMatches, fileCount)
if results.Truncated {
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
fmt.Fprintf(&sb, " (truncated, total: %d)", results.Total)
}
sb.WriteString(":\n\n")
} else if results.Truncated {
sb.WriteString(fmt.Sprintf("(truncated, showing subset of %d total matches)\n\n", results.Total))
} 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)
}
// Write results grouped by file
@@ -408,7 +430,7 @@ func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatO
}
}
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
fmt.Fprintf(&sb, "**%s**\n", relPath)
if opts.Cluster {
writeClusteredResults(&sb, fileResults[file])
@@ -486,10 +508,11 @@ func writeClusteredResults(sb *strings.Builder, results []Result) {
}
}
// truncateLine truncates a line if it exceeds maxLen.
// truncateLine truncates a line if it exceeds maxLen bytes, backing off to a
// rune boundary so a multibyte character is never split into invalid UTF-8.
func truncateLine(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
}
+104
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))
@@ -340,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)
}
}
+158 -7
View File
@@ -55,16 +55,18 @@ func Epsilon() int { return 2 }
// ---- Feature 1: ast_query format flag ----
func TestASTQueryFormatVerboseDefault(t *testing.T) {
func TestASTQueryFormatCompactDefault(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
// 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)
@@ -72,10 +74,19 @@ func TestASTQueryFormatVerboseDefault(t *testing.T) {
if res == nil || len(res.Content) == 0 {
t.Fatal("nil/empty result")
}
text := res.Content[0].(mcp.TextContent).Text
// verbose mode has code blocks
if !strings.Contains(text, "```") {
t.Errorf("verbose mode should have code blocks, got:\n%s", text)
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)
}
}
@@ -139,8 +150,115 @@ func TestASTQueryFormatLocation(t *testing.T) {
}
}
// TestASTQuerySkipsDependencyDirs verifies ast_query excludes node_modules/vendor
// by default (they flood results with third-party code) but still searches them
// when the caller targets one explicitly via paths (the walk root is never skipped).
func TestASTQuerySkipsDependencyDirs(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
deps := map[string]string{
"node_modules": "NodeModulesDep",
"vendor": "VendorDep",
}
for dir, fn := range deps {
full := filepath.Join(tmpDir, dir)
if err := os.MkdirAll(full, 0o750); err != nil {
t.Fatal(err)
}
src := "package dep\n\nfunc " + fn + "() string { return \"\" }\n"
if err := os.WriteFile(filepath.Join(full, "dep.go"), []byte(src), 0o600); err != nil {
t.Fatal(err)
}
}
// Default: workspace-root query must skip the dependency dirs.
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
"format": "location",
}
res, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("default query error: %v", err)
}
// location format prints file:line, so assert on paths.
text := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "a.go") {
t.Errorf("default query should find top-level a.go, got:\n%s", text)
}
for dir := range deps {
if strings.Contains(text, dir+string(filepath.Separator)) {
t.Errorf("default query must skip %s, got:\n%s", dir, text)
}
}
// Explicit target: querying inside node_modules must still find its code.
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{filepath.Join(tmpDir, "node_modules")},
"format": "location",
}
res2, err := srv.handleASTQuery(ctx, req2)
if err != nil {
t.Fatalf("explicit-target query error: %v", err)
}
text2 := res2.Content[0].(mcp.TextContent).Text
if !strings.Contains(text2, "node_modules"+string(filepath.Separator)+"dep.go") {
t.Errorf("explicit node_modules target must find node_modules/dep.go, got:\n%s", text2)
}
}
// 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()
@@ -281,6 +399,39 @@ func TestFileSearchCursorStaleHash(t *testing.T) {
}
}
// TestFileSearchFirstPageCursor is a strict regression guard: when the first
// page is capped by max_results but more matches exist, the response MUST carry
// a cursor so the caller can page. Previously offset==0 emitted "(truncated...)"
// with no cursor, silently breaking pagination on the common path.
func TestFileSearchFirstPageCursor(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
if srv.searcher == nil {
t.Skip("rg not available")
}
ctx := context.Background()
// Corpus has 5 lines containing "func"; cap at 1 forces truncation on page 1.
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
"max_results": float64(1),
"context_lines": float64(0),
}
res, err := srv.handleFileSearch(ctx, req)
if err != nil {
t.Fatalf("file_search error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "[cursor:") {
t.Errorf("first page truncated at max_results=1 with 5 matches must emit a cursor, got:\n%s", text)
}
// The dead-end prose must not be used when a cursor is available.
if strings.Contains(text, "(truncated, showing subset") {
t.Errorf("dead-end truncation prose should be replaced by a cursor footer, got:\n%s", text)
}
}
func TestFileSearchPaginationCursor(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
if srv.searcher == nil {
+51 -36
View File
@@ -60,14 +60,17 @@ func (s *Server) resolveASTQueryParams(request mcp.CallToolRequest) (*astQueryPa
prefsMaxResults = sp.DefaultMaxResults
prefsFormat = sp.ASTQueryFormat
}
p.maxResults = effectiveInt(request, "max_results", prefsMaxResults, 100)
p.maxResults = effectiveInt(request, "max_results", prefsMaxResults, 50)
p.format = request.GetString("format", "")
if p.format == "" {
if prefsFormat != "" {
p.format = prefsFormat
} else {
p.format = "verbose"
// Default to compact (one line per match): far fewer tokens than
// verbose (full code + captures) on multi-match queries. Callers can
// opt into verbose per-call or via the default_format session pref.
p.format = "compact"
}
}
@@ -106,12 +109,13 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
},
}
// Collect limit for early-exit: when paginating we need all results first.
collectLimit := p.maxResults
if p.offset > 0 {
collectLimit = 0
}
// 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) {
@@ -128,7 +132,10 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
return nil
}
if info.IsDir() {
if strings.HasPrefix(info.Name(), ".") {
// Skip hidden and dependency dirs (third-party code that would
// flood results), but never the walk root itself — the caller
// explicitly targeted it, e.g. paths:["vendor/foo"].
if path != searchPath && (strings.HasPrefix(info.Name(), ".") || isDependencyDir(info.Name())) {
return filepath.SkipDir
}
return nil
@@ -137,11 +144,13 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
return nil
}
content, err := os.ReadFile(path)
if err != nil {
// Skip oversized files before reading them off disk.
if info.Size() > s.cfg.MaxFileSize {
return nil
}
if int64(len(content)) > s.cfg.MaxFileSize {
content, err := os.ReadFile(path)
if err != nil {
return nil
}
@@ -154,10 +163,6 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
return nil
}
allResults = append(allResults, matches...)
if collectLimit > 0 && len(allResults) >= collectLimit {
return filepath.SkipAll
}
return nil
})
if walkErr != nil {
@@ -167,6 +172,19 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
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 {
@@ -222,16 +240,9 @@ func (s *Server) handleASTQuery(ctx context.Context, request mcp.CallToolRequest
allResults := s.runASTQueryWalk(ctx, p, exts)
cursorFooter := buildASTCursorFooter(len(allResults), p.offset, p.maxResults, p.queryHash)
output := query.FormatResultsWithOptions(allResults, p.maxResults, p.format, p.offset, p.verbose)
if cursorFooter != "" {
// Replace the [remaining: N] placeholder emitted by FormatResultsWithOptions
// with the full [cursor: ..., remaining: N] line.
output = strings.ReplaceAll(output, fmt.Sprintf("[remaining: %d]\n", len(allResults)-p.offset-p.maxResults), cursorFooter+"\n")
// Fallback: if placeholder wasn't present (e.g. format=location), append footer.
if !strings.Contains(output, cursorFooter) {
output = strings.TrimRight(output, "\n") + "\n" + cursorFooter + "\n"
}
}
// 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
}
@@ -252,25 +263,29 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
var sb strings.Builder
// Get relative path
relPath := path
if absPath, err := filepath.Abs(path); err == nil {
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
relPath = rel
}
}
sb.WriteString(fmt.Sprintf("**%s** (%d lines, %s)\n\n", relPath, len(splitLines(string(content))), lang))
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", s.relPath(path), countLines(content), lang)
sb.WriteString("Symbols:\n")
for _, sym := range symbols {
kindStr := symbolKindIcon(sym.Kind)
sb.WriteString(fmt.Sprintf(" %s %s L%d\n", kindStr, sym.Name, sym.Location.Line))
fmt.Fprintf(&sb, " %s %s L%d\n", kindStr, sym.Name, sym.Location.Line)
}
return sb.String()
}
// 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 {
+9 -10
View File
@@ -92,22 +92,21 @@ func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*
return mcp.NewToolResultText(fmt.Sprintf("+%d -%d", added, removed)), nil
case "diff":
var output strings.Builder
output.WriteString("Diff:\n```diff\n")
output.WriteString(result.Diff)
output.WriteString("```\n")
return mcp.NewToolResultText(output.String()), nil
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
default:
// Fallback: treat unknown values as "diff" for safety.
var output strings.Builder
output.WriteString("Diff:\n```diff\n")
output.WriteString(result.Diff)
output.WriteString("```\n")
return mcp.NewToolResultText(output.String()), nil
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
+27
View File
@@ -59,6 +59,33 @@ func TestEditApplyPreservesBackslashSequencesText(t *testing.T) {
}
}
// 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) {
+113 -17
View File
@@ -3,6 +3,7 @@ package server
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
@@ -15,10 +16,19 @@ import (
"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()
@@ -41,7 +51,7 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
fileTypes := request.GetStringSlice("file_types", nil)
ignoreCase := request.GetBool("ignore_case", false)
regex := request.GetBool("regex", true)
contextLines := request.GetInt("context_lines", 2)
contextLines := request.GetInt("context_lines", 0)
// Consult session prefs for max_results and cluster when not explicitly supplied.
prefs := s.sessionPrefs.Load()
@@ -51,7 +61,10 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
prefsMaxResults = prefs.DefaultMaxResults
prefsCluster = prefs.DefaultCluster
}
maxResults := effectiveInt(request, "max_results", prefsMaxResults, 0)
// Default to the configured cap (not 0/unbounded) so a broad pattern can't
// flood memory and the context window; an explicit max_results still wins,
// and the cursor footer lets the caller page through the rest.
maxResults := effectiveInt(request, "max_results", prefsMaxResults, s.cfg.MaxSearchResults)
cluster := effectiveBool(request, "cluster", prefsCluster, false)
cursorStr := request.GetString("cursor", "")
@@ -94,6 +107,7 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
Regex: regex,
ContextLines: contextLines,
MaxResults: rgMaxResults,
IncludeHidden: request.GetBool("include_hidden", false),
}
results, err := s.searcher.Search(ctx, req)
@@ -113,13 +127,23 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
// Apply in-process max_results cap and compute cursor footer.
var cursorLine string
if maxResults > 0 && len(results.Results) > maxResults {
switch {
case maxResults > 0 && len(results.Results) > maxResults:
// Cursor pages (offset>0) fetch all results, so cap here and emit a cursor.
remaining := len(results.Results) - maxResults
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",
@@ -184,7 +208,7 @@ func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest
}
result, err := s.readOneFile(ctx, request, p)
if err != nil {
output.WriteString(fmt.Sprintf("--- %s ---\n[error: %s]\n", p, errors.SanitizeError(err)))
fmt.Fprintf(&output, "--- %s ---\n[error: %s]\n", p, errors.SanitizeError(err))
continue
}
// Extract etag from result footer for dedup check.
@@ -192,12 +216,12 @@ func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest
if etag != "" {
if firstPath, seen := seenEtag[etag]; seen {
// Duplicate content: emit pointer instead of full content.
output.WriteString(fmt.Sprintf("--- %s ---\n[duplicate of %s, etag: %s]\n", p, firstPath, etag))
fmt.Fprintf(&output, "--- %s ---\n[duplicate of %s, etag: %s]\n", p, firstPath, etag)
continue
}
seenEtag[etag] = p
}
output.WriteString(fmt.Sprintf("--- %s ---\n%s", p, result))
fmt.Fprintf(&output, "--- %s ---\n%s", p, result)
}
return mcp.NewToolResultText(output.String()), nil
}
@@ -304,7 +328,11 @@ func (s *Server) resolveLineNumberOpts(request mcp.CallToolRequest) lineNumberOp
opts := lineNumberOpts{
noLineNumbers: request.GetBool("no_line_numbers", false),
lineInterval: request.GetInt("line_number_interval", 1),
compactLineNums: request.GetBool("compact_line_numbers", false),
// 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.
@@ -439,12 +467,19 @@ func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, p
// Symbol-based line range: find the symbol and use its exact bounds.
if symbolName != "" {
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
start, end, found := s.resolveSymbolLines(ctx, path, content, symbolName, symbolKind)
if !found {
return "", fmt.Errorf("symbol %q not found in %s", symbolName, path)
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))
}
lineStart = start
lineEnd = end
}
// Clamp to valid range.
@@ -499,14 +534,60 @@ func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, p
return output.String(), nil
}
// resolveSymbolLines parses the AST and returns the line range of the named symbol.
// symbolKind optionally filters by kind (empty = any).
func (s *Server) resolveSymbolLines(ctx context.Context, path string, content []byte, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
// 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
return nil
}
return parser.FindSymbolRange(result.Tree, content, path, symbolName, symbolKind)
return parser.FindSymbolCandidates(result.Tree, content, path, symbolName, symbolKind)
}
// nearestSymbolSuggestion returns a " — did you mean: a, b, c?" hint listing the
// closest symbol names in the file to name, or "" if none are reasonably close.
// The re-parse is cheap: the tree is served from the parser cache.
func (s *Server) nearestSymbolSuggestion(ctx context.Context, path string, content []byte, name string) string {
result, err := s.parser.Parse(ctx, path, content)
if err != nil {
return ""
}
syms := parser.ExtractSymbols(result.Tree, content, result.Language, path)
if len(syms) == 0 {
return ""
}
names := make([]string, 0, len(syms))
seen := make(map[string]bool, len(syms))
for _, sym := range syms {
if !seen[sym.Name] {
seen[sym.Name] = true
names = append(names, sym.Name)
}
}
matches := fuzzy.New(maxSymbolSuggestionDistance).Match(name, names)
if len(matches) == 0 {
return ""
}
top := matches
if len(top) > maxSymbolSuggestions {
top = top[:maxSymbolSuggestions]
}
suggestions := make([]string, len(top))
for i, m := range top {
suggestions[i] = m.Text
}
return " — did you mean: " + strings.Join(suggestions, ", ") + "?"
}
// formatSymbolCandidates renders ambiguous symbol matches as a compact,
// comma-separated list, e.g. "method_declaration L10-24, method_declaration L42-58".
func formatSymbolCandidates(candidates []parser.SymbolCandidate) string {
parts := make([]string, len(candidates))
for i, c := range candidates {
parts[i] = fmt.Sprintf("%s L%d-%d", c.NodeType, c.StartLine, c.EndLine)
}
return strings.Join(parts, ", ")
}
// writeLines writes the selected line range into output, applying all formatting options.
@@ -571,6 +652,21 @@ func extractEtag(result string) string {
// 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
+34 -8
View File
@@ -266,6 +266,28 @@ struct Foo {
// ---- 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)
@@ -567,19 +589,23 @@ func TestCompactLineNumbers(t *testing.T) {
}
}
func TestCompactLineNumbersOffByDefault(t *testing.T) {
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: no compact_line_numbers
out := callRead(t, srv, map[string]interface{}{"path": f})
// 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)
}
// Should have padded format
if !strings.Contains(out, " 1│ line one") {
t.Errorf("default should have padded format, got:\n%s", out)
// 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)
}
}
@@ -657,9 +683,9 @@ func Hello() {
if !strings.Contains(out, `println("hello")`) {
t.Errorf("full mode missing body, got:\n%s", out)
}
// Padded line numbers
// Default line numbers use the compact prefix.
if !strings.Contains(out, "1│package main") {
t.Errorf("default should have padded line numbers, got:\n%s", out)
t.Errorf("default should have compact line numbers, got:\n%s", out)
}
// etag present
if !strings.Contains(out, "[etag:") {
+66 -22
View File
@@ -10,6 +10,7 @@ import (
"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"
)
@@ -26,20 +27,30 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
return mcp.NewToolResultError("file is required"), nil
}
line := request.GetInt("line", 0)
if line <= 0 {
return mcp.NewToolResultError("line must be positive"), nil
}
col := request.GetInt("column", 0)
if col <= 0 {
return mcp.NewToolResultError("column must be positive"), nil
}
if !s.cfg.IsPathAllowed(file) {
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 {
@@ -63,12 +74,14 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
case "references":
includeDecl := request.GetBool("include_declaration", true)
// compact: explicit call-time > session compact_refs pref > false
// 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, false)
compact := effectiveBool(request, "compact", prefsCompact, true)
return s.lspReferences(ctx, file, line, col, includeDecl, compact, verbose)
default:
@@ -76,6 +89,31 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
}
}
// resolveLSPSymbolPosition resolves a symbol name to the 1-indexed line:column of
// its name identifier, for use as an LSP position. Returns an error result when the
// file can't be read/parsed, the symbol is not found (with a "did you mean" hint),
// or the name is ambiguous (listing candidates).
func (s *Server) resolveLSPSymbolPosition(ctx context.Context, file, symbolName string, symbolKind protocol.SymbolKind) (line, col int, errResult *mcp.CallToolResult) {
content, err := os.ReadFile(file)
if err != nil {
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to read file: %s", errors.SanitizeError(err)))
}
result, err := s.parser.Parse(ctx, file, content)
if err != nil {
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to parse file: %s", errors.SanitizeError(err)))
}
candidates := parser.FindSymbolCandidates(result.Tree, content, file, symbolName, symbolKind)
switch len(candidates) {
case 0:
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q not found in %s%s", symbolName, file, s.nearestSymbolSuggestion(ctx, file, content, symbolName)))
case 1:
return candidates[0].NameLine, candidates[0].NameColumn, nil
default:
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q is ambiguous in %s (%d matches): %s — disambiguate with symbol_kind, or pass line+column",
symbolName, file, len(candidates), formatSymbolCandidates(candidates)))
}
}
// lspHover performs hover (symbol info) for the given position.
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)
@@ -115,10 +153,14 @@ func (s *Server) handleSymbolAtFallback(ctx context.Context, file string, line,
var output strings.Builder
if verbose {
output.WriteString("**Symbol Information** (AST fallback)\n\n")
output.WriteString("**Symbol Information** (AST fallback — LSP unavailable)\n\n")
} else {
// Always signal the degraded result so the model knows this is an AST
// guess, not real LSP type info, and can decide whether to retry.
output.WriteString("[AST fallback — LSP unavailable]\n")
}
output.WriteString(fmt.Sprintf("Node type: `%s`\n", node.Type()))
output.WriteString(fmt.Sprintf("Text: `%s`\n", parser.GetNodeText(node, content)))
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
}
@@ -136,12 +178,12 @@ func (s *Server) lspDefinition(ctx context.Context, file string, line, col int,
var output strings.Builder
if verbose {
output.WriteString(fmt.Sprintf("Found %d definition(s):\n\n", len(locations)))
fmt.Fprintf(&output, "Found %d definition(s):\n\n", len(locations))
}
for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI)
output.WriteString(fmt.Sprintf("**%s:%d:%d**\n", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1))
fmt.Fprintf(&output, "**%s:%d:%d**\n", 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 != "" {
@@ -170,7 +212,9 @@ func (s *Server) lspReferences(ctx context.Context, file string, line, col int,
fileGroups := make(map[string][]lsp.Location)
fileOrder := make([]string, 0)
for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI)
// 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)
}
@@ -187,7 +231,7 @@ func (s *Server) lspReferences(ctx context.Context, file string, line, col int,
func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string, total int, compact bool, verbose bool) string {
var output strings.Builder
if verbose {
output.WriteString(fmt.Sprintf("Found %d reference(s):\n\n", total))
fmt.Fprintf(&output, "Found %d reference(s):\n\n", total)
}
for _, filePath := range fileOrder {
@@ -195,9 +239,9 @@ func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string,
if compact {
output.WriteString(formatReferencesCompact(filePath, locs))
} else {
output.WriteString(fmt.Sprintf("**%s** (%d)\n", filePath, len(locs)))
fmt.Fprintf(&output, "**%s** (%d)\n", filePath, len(locs))
for _, loc := range locs {
output.WriteString(fmt.Sprintf(" L%d:%d\n", loc.Range.Start.Line+1, loc.Range.Start.Character+1))
fmt.Fprintf(&output, " L%d:%d\n", loc.Range.Start.Line+1, loc.Range.Start.Character+1)
}
output.WriteString("\n")
}
@@ -273,7 +317,7 @@ func (s *Server) readFilePreview(file string, line, contextLines int) string {
if i+1 == line {
prefix = "> "
}
preview.WriteString(fmt.Sprintf("%s%4d: %s\n", prefix, i+1, lineText))
fmt.Fprintf(&preview, "%s%4d: %s\n", prefix, i+1, lineText)
}
return preview.String()
+63
View File
@@ -1,12 +1,25 @@
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{
@@ -179,3 +192,53 @@ func TestFormatReferencesVerbosePreservesLPrefix(t *testing.T) {
t.Errorf("verbose output must contain L3:4, got:\n%s", out)
}
}
// resolveLSPSymbolPosition underpins lsp_query's symbol_name affordance: it maps
// a symbol name to the line:column of its name identifier, or returns an error
// result for not-found / ambiguous names. It does not require a running LSP.
func TestResolveLSPSymbolPosition(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\n" + // 1
"\n" + // 2
"type A struct{}\n" + // 3
"func (a A) Run() {}\n" + // 4
"\n" + // 5
"type B struct{}\n" + // 6
"func (b B) Run() {}\n" + // 7
"\n" + // 8
"func Unique() {}\n" // 9
f := writeFile(t, tmpDir, "main.go", goSrc)
ctx := context.Background()
t.Run("unique symbol resolves to its name position", func(t *testing.T) {
line, col, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Unique", "")
if errRes != nil {
t.Fatalf("unexpected error result for Unique: %s", errorText(errRes))
}
if line != 9 || col != 6 {
t.Errorf("Unique position = %d:%d, want 9:6", line, col)
}
})
t.Run("ambiguous symbol errors", func(t *testing.T) {
_, _, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Run", "")
if errRes == nil {
t.Fatal("expected ambiguity error for Run")
}
if !strings.Contains(errorText(errRes), "ambiguous") {
t.Errorf("expected 'ambiguous' in error, got: %s", errorText(errRes))
}
})
t.Run("missing symbol errors", func(t *testing.T) {
_, _, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Nope", "")
if errRes == nil {
t.Fatal("expected not-found error for Nope")
}
if !strings.Contains(errorText(errRes), "not found") {
t.Errorf("expected 'not found' in error, got: %s", errorText(errRes))
}
})
}
+23 -4
View File
@@ -49,7 +49,7 @@ const helpFileSearch = "# file_search — flags and examples\n\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" +
"| `context_lines` | Number of context lines around matches (default: 0). |\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" +
@@ -75,9 +75,9 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" +
"| Flag | Effect |\n" +
"|------|--------|\n" +
"| `verbose=true` | Emit `Found N match(es):` preamble (v1 behaviour). Default: false. |\n" +
"| `format` | `verbose` (default, full code+captures) \\| `compact` (one line per match) \\| `location` (file:line only) |\n" +
"| `format` | `compact` (default, one line per match) \\| `verbose` (full code+captures) \\| `location` (file:line only) |\n" +
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
"| `max_results` | Page size (default: 100). |\n" +
"| `max_results` | Page size (default: 50). |\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" +
@@ -88,9 +88,22 @@ const helpASTQuery = "# ast_query — flags and examples\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 returning error\n" +
"// All Go functions/methods (the 'error' return is NOT enforced — add name_/kind_in to narrow)\n" +
`{"pattern": "func $NAME($$$ARGS) error", "language": "go"}` + "\n\n" +
"// Python classes\n" +
`{"pattern": "class $NAME: $$$BODY", "language": "python"}` + "\n\n" +
@@ -114,10 +127,16 @@ const helpLSPQuery = "# lsp_query — flags and examples\n\n" +
"- `compact=true` — collapse to one line per file\n" +
"- `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" +
+22 -12
View File
@@ -54,7 +54,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
logger: logger,
parser: parserRegistry,
matcher: query.NewMatcher(parserRegistry),
editor: edit.NewEngine(parserRegistry),
editor: edit.NewEngineWithValidator(parserRegistry, cfg.IsPathAllowed),
readSem: make(chan struct{}, MaxConcurrentReads),
querySem: make(chan struct{}, MaxConcurrentQueries),
}
@@ -129,11 +129,14 @@ func (s *Server) registerTools() {
mcp.WithBoolean("ignore_case",
mcp.Description("Case insensitive search"),
),
mcp.WithBoolean("include_hidden",
mcp.Description("Search hidden files and directories (dotfiles like .github/). Default: false."),
),
mcp.WithBoolean("regex",
mcp.Description("Treat pattern as regex (default: true)"),
),
mcp.WithNumber("context_lines",
mcp.Description("Number of context lines around matches (default: 2)"),
mcp.Description("Number of context lines around matches (default: 0)"),
),
mcp.WithNumber("max_results",
mcp.Description("Maximum number of results to return (page size for pagination)"),
@@ -190,7 +193,7 @@ func (s *Server) registerTools() {
mcp.Description("Output mode: 'full' (default, full file), 'skeleton' (signatures + { ... } stubs, bodies elided), 'symbols_only' (symbol list only, alias for symbols_only=true)."),
),
mcp.WithArray("strip",
mcp.Description("Strip content classes before line-numbering. Values: 'imports' (remove import blocks), 'license' (remove leading license comment), 'block_comments' (remove /* */ and Python triple-quoted strings). Emits [stripped: ...] footer."),
mcp.Description("Strip content classes before line-numbering. Values: 'imports' (remove import blocks), 'license' (remove leading license comment), 'block_comments' (remove /* */ and Python triple-quoted strings). Emits [stripped: ...] footer. Note: line numbers are renumbered from 1 over the stripped content, so they no longer match the file's original line numbers."),
mcp.WithStringItems(),
),
mcp.WithNumber("max_lines",
@@ -203,7 +206,7 @@ func (s *Server) registerTools() {
mcp.Description("Print line numbers only every N lines (default: 1 = every line). E.g. 10 = anchor every 10th line plus first/last. 0 = no line numbers."),
),
mcp.WithBoolean("compact_line_numbers",
mcp.Description("Use compact line prefix '12\u2502' instead of ' 12\u2502 ' (no padding, no trailing space). Works with line_number_interval. Default off."),
mcp.Description("Use compact line prefix '12\u2502' instead of padded ' 12\u2502 ' (no padding, no trailing space). Works with line_number_interval. Default true; set false to restore padded alignment."),
),
mcp.WithBoolean("collapse_blank_lines",
mcp.Description("Collapse runs of consecutive blank lines to a single blank line. Useful for token savings on heavily-spaced code."),
@@ -222,6 +225,8 @@ func (s *Server) registerTools() {
s.mcp.AddTool(
mcp.NewTool("ast_query",
mcp.WithDescription("Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types. "+
"Matching is keyword/kind-based, not fully structural: argument and return-type constraints in the pattern are not enforced — use name_exact/name_matches/kind_in to filter precisely. "+
"Hidden dirs and dependency dirs (node_modules, vendor, bower_components) are skipped unless targeted explicitly via paths. "+
"See resource filepuff://help/ast_query for flags and examples."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("pattern",
@@ -247,10 +252,10 @@ func (s *Server) registerTools() {
mcp.WithStringItems(),
),
mcp.WithNumber("max_results",
mcp.Description("Maximum number of results to return (default: 100, page size for pagination)"),
mcp.Description("Maximum number of results to return (default: 50, page size for pagination)"),
),
mcp.WithString("format",
mcp.Description("Output format: \"verbose\" (default, full code+captures), \"compact\" (one line per match), \"location\" (file:line only)"),
mcp.Description("Output format: \"compact\" (default, one line per match), \"verbose\" (full code+captures), \"location\" (file:line only)"),
),
mcp.WithString("cursor",
mcp.Description("Pagination cursor from a previous truncated response. Pass back to fetch the next page."),
@@ -266,7 +271,8 @@ func (s *Server) registerTools() {
if s.lspManager != nil {
s.mcp.AddTool(
mcp.NewTool("lsp_query",
mcp.WithDescription("Query LSP for symbol info, definition, or references at a specific file position. "+
mcp.WithDescription("Query LSP for symbol info, definition, or references at a file position. "+
"Give the position as line+column, or pass symbol_name to resolve it via AST (no line/column needed). "+
"See resource filepuff://help/lsp_query for flags and examples."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("action",
@@ -277,19 +283,23 @@ func (s *Server) registerTools() {
mcp.Required(),
mcp.Description("Path to the file"),
),
mcp.WithString("symbol_name",
mcp.Description("Resolve the position from a named symbol (function, struct, class, etc.) instead of line+column. Errors if the name is ambiguous or not found."),
),
mcp.WithString("symbol_kind",
mcp.Description("Disambiguate symbol_name by kind when multiple symbols share the name. Accepted: function, method, struct, class, interface, type, enum, trait, constant, module."),
),
mcp.WithNumber("line",
mcp.Required(),
mcp.Description("Line number (1-indexed)"),
mcp.Description("Line number (1-indexed). Required unless symbol_name is given."),
),
mcp.WithNumber("column",
mcp.Required(),
mcp.Description("Column number (1-indexed)"),
mcp.Description("Column number (1-indexed). Required unless symbol_name is given."),
),
mcp.WithBoolean("include_declaration",
mcp.Description("Include the declaration in results. Only valid for action=references (default: true)."),
),
mcp.WithBoolean("compact",
mcp.Description("Compact output: one line per file with all refs in brackets. Only valid for action=references. Default: false."),
mcp.Description("Compact output: one line per file with all refs in brackets. Only valid for action=references. Default: true; set false for one line per reference."),
),
mcp.WithBoolean("verbose",
mcp.Description("Emit count/header preamble. Applies to all actions. Default: false."),
+26
View File
@@ -415,6 +415,32 @@ func TestSplitLinesLargeFileNoTrailingNewline(t *testing.T) {
}
}
// TestCountLines verifies countLines reports source line counts without the
// phantom trailing line that len(splitLines(...)) produces for newline-terminated
// files.
func TestCountLines(t *testing.T) {
tests := []struct {
name string
content string
want int
}{
{name: "empty", content: "", want: 0},
{name: "single line no newline", content: "a", want: 1},
{name: "single line trailing newline", content: "a\n", want: 1},
{name: "two lines trailing newline", content: "a\nb\n", want: 2},
{name: "two lines no trailing newline", content: "a\nb", want: 2},
{name: "blank lines", content: "\n\n\n", want: 3},
{name: "crlf trailing", content: "a\r\nb\r\n", want: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := countLines([]byte(tt.content)); got != tt.want {
t.Errorf("countLines(%q) = %d, want %d", tt.content, got, tt.want)
}
})
}
}
// TestSplitLinesLongLine verifies the scanner gracefully handles very long lines
// (up to the 1MB buffer limit set in C-05 fix).
func TestSplitLinesLongLine(t *testing.T) {
+4 -4
View File
@@ -48,7 +48,7 @@ func TestSessionPrefsFileReadLineNumbersNone(t *testing.T) {
t.Fatalf("handleFileRead: %v", err)
}
text := result.Content[0].(mcp.TextContent).Text
// Standard line-number format is " 1│ "; with no_line_numbers it's absent.
// 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)
}
@@ -87,7 +87,7 @@ func TestSessionPrefsFileReadLineNumbersCompact(t *testing.T) {
req.Params.Arguments = map[string]interface{}{"path": testFile}
result, _ := srv.handleFileRead(ctx, req)
text := result.Content[0].(mcp.TextContent).Text
// Standard padded prefix is " 1│ "; compact is "1│".
// 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)
}
@@ -199,7 +199,7 @@ func TestSessionPrefsASTQueryMaxResults(t *testing.T) {
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < 5; i++ {
sb.WriteString(fmt.Sprintf("func Fn%c() {}\n\n", rune('A'+i)))
fmt.Fprintf(&sb, "func Fn%c() {}\n\n", rune('A'+i))
}
testFile := filepath.Join(tmpDir, "many.go")
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
@@ -281,7 +281,7 @@ func TestSessionPrefsMaxResultsExplicitOverride(t *testing.T) {
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < 5; i++ {
sb.WriteString(fmt.Sprintf("func Fn%c() {}\n\n", rune('A'+i)))
fmt.Fprintf(&sb, "func Fn%c() {}\n\n", rune('A'+i))
}
testFile := filepath.Join(tmpDir, "many.go")
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
+20
View File
@@ -0,0 +1,20 @@
package util
import "unicode/utf8"
// RuneBoundary returns the largest index j <= i such that s[:j] is valid UTF-8
// (j falls on a rune start, never inside a multibyte sequence). i is clamped to
// [0, len(s)]. Callers slicing s[:i] for truncation should slice s[:RuneBoundary(s, i)]
// instead so a multibyte character is never split into an invalid encoding.
func RuneBoundary(s string, i int) int {
if i <= 0 {
return 0
}
if i >= len(s) {
return len(s)
}
for i > 0 && !utf8.RuneStart(s[i]) {
i--
}
return i
}
+37
View File
@@ -0,0 +1,37 @@
package util
import (
"testing"
"unicode/utf8"
)
func TestRuneBoundary(t *testing.T) {
// "café" — 'é' is 2 bytes (0xC3 0xA9), occupying indices 3,4. len == 5.
const cafe = "café"
cases := []struct {
name string
s string
i int
want int
}{
{"negative clamps to zero", "abc", -1, 0},
{"zero", "abc", 0, 0},
{"beyond length clamps to len", "abc", 99, 3},
{"ascii exact boundary", "abcdef", 4, 4},
{"inside multibyte backs off", cafe, 4, 3}, // index 4 is the 2nd byte of 'é'
{"on multibyte start stays", cafe, 3, 3}, // index 3 is the 1st byte of 'é'
{"end of multibyte string", cafe, 5, 5}, // len == 5, clamped path
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := RuneBoundary(c.s, c.i)
if got != c.want {
t.Fatalf("RuneBoundary(%q, %d) = %d, want %d", c.s, c.i, got, c.want)
}
// Invariant: the prefix is always valid UTF-8.
if !utf8.ValidString(c.s[:got]) {
t.Fatalf("RuneBoundary(%q, %d) sliced invalid UTF-8: %q", c.s, c.i, c.s[:got])
}
})
}
}
+5 -5
View File
@@ -77,24 +77,24 @@ func (e *StructuredError) Error() string {
var sb strings.Builder
// 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()
@@ -215,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