Compare commits

...
19 Commits
Author SHA1 Message Date
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
29 changed files with 840 additions and 348 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0
github.com/goccy/go-json v0.10.6
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mark3labs/mcp-go v0.55.0
github.com/mark3labs/mcp-go v0.55.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
@@ -18,5 +18,5 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
+4 -4
View File
@@ -24,8 +24,8 @@ 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.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mark3labs/mcp-go v0.55.1 h1:GLYqNm9qdMGPhCtK4g1t1y1vhAPfayOBuaibDi4mrSA=
github.com/mark3labs/mcp-go v0.55.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -44,8 +44,8 @@ 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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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=
+36 -32
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{},
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,
@@ -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)] + "..."
}
+144
View File
@@ -369,6 +369,73 @@ 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) {
@@ -1751,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)
}
}
}
+19 -1
View File
@@ -304,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)
@@ -380,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)
}
@@ -398,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()
}
+38
View File
@@ -1,10 +1,48 @@
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) {
+14 -2
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 {
+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")
}
})
}
+59 -21
View File
@@ -1,6 +1,8 @@
package parser
import (
"strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
sitter "github.com/smacker/go-tree-sitter"
)
@@ -425,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),
}
}
@@ -484,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),
}
}
+30
View File
@@ -125,6 +125,36 @@ 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) {
+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)
}
})
}
}
+3
View File
@@ -186,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)
+1 -1
View File
@@ -583,7 +583,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
}
+21 -6
View File
@@ -10,10 +10,10 @@ import (
"os/exec"
"path/filepath"
"strings"
"unicode/utf8"
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"
)
@@ -258,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{
@@ -288,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
@@ -495,9 +514,5 @@ func truncateLine(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
end := max(maxLen-3, 0)
for end > 0 && !utf8.RuneStart(s[end]) {
end--
}
return s[:end] + "..."
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
}
+87
View File
@@ -13,6 +13,51 @@ import (
"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) {
@@ -357,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)
}
}
+140
View File
@@ -150,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()
@@ -292,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 {
+37 -20
View File
@@ -109,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) {
@@ -131,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
@@ -159,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 {
@@ -172,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 {
@@ -250,15 +263,7 @@ 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
}
}
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", relPath, countLines(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 {
@@ -269,6 +274,18 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
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) {
+18 -4
View File
@@ -127,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",
@@ -316,9 +326,13 @@ type lineNumberOpts struct {
// resolveLineNumberOpts resolves per-call vs session-pref line-number options.
func (s *Server) resolveLineNumberOpts(request mcp.CallToolRequest) lineNumberOpts {
opts := lineNumberOpts{
noLineNumbers: request.GetBool("no_line_numbers", false),
lineInterval: request.GetInt("line_number_interval", 1),
compactLineNums: request.GetBool("compact_line_numbers", false),
noLineNumbers: request.GetBool("no_line_numbers", false),
lineInterval: request.GetInt("line_number_interval", 1),
// Compact prefix ("12│content") is the default — it drops the 4-wide
// padding and trailing space of the legacy " 12│ " form, saving ~3
// chars/line with no information loss. Pass compact_line_numbers=false
// (or mode/line_numbers="full") to restore the padded alignment.
compactLineNums: request.GetBool("compact_line_numbers", true),
}
// Apply session line_numbers pref when no explicit per-call override was supplied.
+13 -9
View File
@@ -589,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)
}
}
@@ -679,9 +683,9 @@ func Hello() {
if !strings.Contains(out, `println("hello")`) {
t.Errorf("full mode missing body, got:\n%s", out)
}
// Padded line numbers
if !strings.Contains(out, " 1│ package main") {
t.Errorf("default should have padded line numbers, got:\n%s", out)
// Default line numbers use the compact prefix.
if !strings.Contains(out, "1│package main") {
t.Errorf("default should have compact line numbers, got:\n%s", out)
}
// etag present
if !strings.Contains(out, "[etag:") {
+8 -4
View File
@@ -74,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:
@@ -181,7 +183,7 @@ func (s *Server) lspDefinition(ctx context.Context, file string, line, col int,
for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI)
fmt.Fprintf(&output, "**%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 != "" {
@@ -210,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)
}
+5
View File
@@ -96,6 +96,11 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" +
"Captured `$VAR` values appear in output but do not constrain which nodes match.\n\n" +
"For precise selection, combine the pattern with `name_exact`, `name_matches`, and/or `kind_in` — " +
"these ARE applied as filters.\n\n" +
"## Scope\n\n" +
"The walk skips hidden directories (dot-prefixed) and dependency directories " +
"(`node_modules`, `vendor`, `bower_components`) — these hold third-party code that would " +
"flood results. To query inside one, name it explicitly in `paths` (e.g. `\"paths\": [\"vendor/foo\"]`); " +
"the targeted root is never skipped. Note: unlike file_search, ast_query does not consult .gitignore.\n\n" +
"## Examples\n\n" +
"```json\n" +
"// All Go functions/methods (the 'error' return is NOT enforced — add name_/kind_in to narrow)\n" +
+5 -4
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),
}
@@ -193,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",
@@ -206,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."),
@@ -226,6 +226,7 @@ func (s *Server) registerTools() {
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",
@@ -298,7 +299,7 @@ func (s *Server) registerTools() {
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."),
+2 -2
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)
}
+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])
}
})
}
}