Compare commits

...
13 Commits
Author SHA1 Message Date
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
20 changed files with 438 additions and 296 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.56.0
github.com/sergi/go-diff v1.4.0
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
gopkg.in/yaml.v3 v3.0.1
@@ -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.56.0 h1:7aCj2wODCskMi08f923ADG+EfELZBdiKILny415cIS8=
github.com/mark3labs/mcp-go v0.56.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/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=
+35 -31
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,
@@ -866,14 +881,3 @@ func truncateString(s string, maxLen int) string {
}
return s[:util.RuneBoundary(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
}
+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")
}
})
}
+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)
}
})
}
}
+19
View File
@@ -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
+42
View File
@@ -402,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)
}
}
+14
View File
@@ -213,6 +213,20 @@ func TestASTQuerySkipsDependencyDirs(t *testing.T) {
}
}
// 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
+13 -9
View File
@@ -263,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 {
@@ -282,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 {
+7 -3
View File
@@ -326,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)
}
+3 -3
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),
}
@@ -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."),
@@ -299,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)
}